From 0fe4166d7da12efe3fecd728b02a3ae7ea93b1cf Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 31 Jul 2026 17:36:36 -0400 Subject: [PATCH] loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone --- src/core/instrument/CLAUDE.md | 8 +- src/core/instrument/engine/CMakeLists.txt | 6 +- src/core/instrument/engine/loop/CLAUDE.md | 60 +++++ .../instrument/engine/loop/CMakeLists.txt | 7 + src/core/instrument/engine/loop/loop_span.cpp | 37 +++ src/core/instrument/engine/loop/loop_span.h | 54 ++++ src/core/instrument/engine/play_params.h | 6 + src/core/instrument/engine/voice.cpp | 20 +- src/core/instrument/engine/voice.h | 80 ++++-- .../instrument/map/component_state_io.cpp | 2 +- src/core/instrument/map/component_state_io.h | 17 +- src/core/instrument/map/params_payload.cpp | 8 + src/core/instrument/map/params_payload.h | 2 +- src/core/instrument/map/sample_map.cpp | 2 + src/core/instrument/map/sample_map.h | 7 + src/core/instrument/ui/waveform_view.cpp | 12 + src/core/instrument/ui/waveform_view.h | 9 + .../instrument/editor_input_waveform.cpp | 45 +++- .../instrument/editor_paint_waveform.cpp | 20 ++ src/shell/instrument/editor_session.cpp | 27 +- src/shell/instrument/reasampler_editor.h | 18 +- tests/test_component_state_io.cpp | 131 ++++++++- tests/test_loop_span.cpp | 167 ++++++++++++ tests/test_sampler_core.cpp | 253 ++++++++++++++++++ tests/test_waveform_view.cpp | 57 ++++ 25 files changed, 991 insertions(+), 64 deletions(-) create mode 100644 src/core/instrument/engine/loop/CLAUDE.md create mode 100644 src/core/instrument/engine/loop/CMakeLists.txt create mode 100644 src/core/instrument/engine/loop/loop_span.cpp create mode 100644 src/core/instrument/engine/loop/loop_span.h create mode 100644 tests/test_loop_span.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 9bee9e1..be210d7 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index a0b7620..af6bb07 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/engine/loop/CLAUDE.md b/src/core/instrument/engine/loop/CLAUDE.md new file mode 100644 index 0000000..60c639f --- /dev/null +++ b/src/core/instrument/engine/loop/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/loop/CMakeLists.txt b/src/core/instrument/engine/loop/CMakeLists.txt new file mode 100644 index 0000000..21f98d5 --- /dev/null +++ b/src/core/instrument/engine/loop/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/engine/loop/loop_span.cpp b/src/core/instrument/engine/loop/loop_span.cpp new file mode 100644 index 0000000..900b6ff --- /dev/null +++ b/src/core/instrument/engine/loop/loop_span.cpp @@ -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(out.end - xf); + out.fadeInv = xf > 0 ? 1.0 / static_cast(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 diff --git a/src/core/instrument/engine/loop/loop_span.h b/src/core/instrument/engine/loop/loop_span.h new file mode 100644 index 0000000..ddb11ff --- /dev/null +++ b/src/core/instrument/engine/loop/loop_span.h @@ -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 + +#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(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 diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index b9d4b84..a4f7de5 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -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; diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index e7cad8f..1673d50 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -66,6 +66,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick readPos_ = static_cast(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(i)] = - (q < frameCount) ? pcmCh[static_cast(q)] : 0.0f; + (q < frameCount) + ? crossfadedSource(pcmCh, q, + instrument::engine::loop::crossfadeWeight( + loop_, static_cast(q))) + : 0.0f; ++q; } (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 2d7460b..c82d3dd 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(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& pcm, std::int64_t frameCount, + double pos) { + const std::int64_t i0 = static_cast(pos); + const std::int64_t i1 = i0 + 1; + const double frac = pos - static_cast(i0); + const double a = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; + const double b = (i1 >= 0 && i1 < frameCount) ? static_cast(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& pcm, std::int64_t pos, + double xw) const { + const double v = static_cast(pcm[static_cast(pos)]); + if (xw <= 0.0) return static_cast(v); + const std::int64_t tap = pos - loop_.length; + if (tap < 0) return static_cast(v); + const double in = static_cast(pcm[static_cast(tap)]); + return static_cast(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& 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(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(loop.length); while (readPos_ >= static_cast(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(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(feedPos_)); + const AudioSample feedL = + feedOk ? crossfadedSource(pcm, feedPos_, feedXw) : 0.0f; const double shift = baseRatio_ * envFactor; shiftL_.setShiftRatio(shift); const double shiftedL = static_cast(shiftL_.process(feedL)); @@ -447,7 +468,7 @@ private: // not leak a previous note. if (exhausted) shiftR_.freezeTail(); const AudioSample feedR = - feedOk ? pcmR[static_cast(feedPos_)] : 0.0f; + feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f; shiftR_.setShiftRatio(shift); outRlocal = static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())); @@ -471,7 +492,7 @@ private: const std::int64_t i0 = static_cast(readPos_); const double frac = readPos_ - static_cast(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(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(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 diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index ab72530..10e420d 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -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. diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 745990b..3268006 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 @@ -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 diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 9600993..f0a267d 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -319,6 +319,8 @@ void putParamsPayload(std::vector& 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{}; diff --git a/src/core/instrument/map/params_payload.h b/src/core/instrument/map/params_payload.h index f481c9e..99a291b 100644 --- a/src/core/instrument/map/params_payload.h +++ b/src/core/instrument/map/params_payload.h @@ -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 diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index abf1469..6f8b84e 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -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; diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index e5ccfb8..463d5ae 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -227,6 +227,12 @@ struct InstrumentParams { std::optional loopOverride; // instrument-owned sustain loop; absent -> intrinsic std::optional 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 }; diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index 2fbf2d7..b3347fa 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -67,6 +67,18 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) { return clampFrame(num / static_cast(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; diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index b3b8efc..d30784a 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -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. diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index c814165..e1c9ff2 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -52,20 +52,33 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } } const SetupMarkers m = pickedMarkers(frames); + // The crossfade handle first, and only when there IS a loop to fade: at a zero fade it + // sits exactly on the loop start, so it can only stay reachable by owning the top strip + // (waveform_view.h's handle-vs-column split) and being asked first. + if (m.hasLoop && + contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) { + beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x); + return true; + } const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y); if (hit >= 0) { - drag_ = DragKind::kWaveMarker; - waveMarker_ = static_cast(hit); - dragStartX_ = x; - dragStartMarkers_ = m; - dragSampleFrames_ = frames; - dragStartParams_ = params_; + beginMarkerDrag(static_cast(hit), m, frames, x); return true; } return false; } +void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m, + std::int64_t frames, int x) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = which; + dragStartX_ = x; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; + dragStartParams_ = params_; +} + void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform); const int dx = x - dragStartX_; @@ -96,14 +109,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). const int idx = static_cast(waveMarker_); - const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, - dragStartMarkers_.loopEnd}; + const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, + dragStartMarkers_.loopEnd, + dragStartMarkers_.loopStart - + dragStartMarkers_.crossfade}; std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx); // Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono - // frames — no host types, no file I/O. + // frames — no host types, no file I/O. The crossfade handle is exempt: it sets a fade + // LENGTH, and the whole point of the fade is that its edges need no zero crossing. const std::vector& pcm = monoPcmFor(selectedId_); - if (!pcm.empty()) { + if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) { newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), newFrame); } @@ -116,12 +132,19 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { } else if (waveMarker_ == WaveMarker::kLoopStart) { m.loopStart = (std::min)(newFrame, m.loopEnd); m.hasLoop = true; - } else { // kLoopEnd + } else if (waveMarker_ == WaveMarker::kLoopEnd) { m.loopEnd = (std::max)(newFrame, m.loopStart); m.hasLoop = true; + } else { // kLoopXfade — the handle sits at loopStart - crossfade, so left lengthens it + m.crossfade = (std::max)(std::int64_t{0}, m.loopStart - newFrame); } if (m.start < 0) m.start = 0; if (m.start > frames - 1) m.start = frames - 1; + // Mirror resolveLoop's own bound so the handle can't be dragged somewhere the engine + // would silently clamp back: the fade reads the material ahead of the loop, and cannot + // outrun either that material or the loop itself. + m.crossfade = (std::min)(m.crossfade, (std::min)(m.loopStart, m.loopEnd - m.loopStart)); + if (m.crossfade < 0) m.crossfade = 0; applyMarkers(m); invalidate(); // live feedback; the commit lands on release diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 53a5455..82ebbf7 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -98,6 +98,17 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { static_cast(kLoopSpanFillAlpha), 0); } } + // The crossfade region, at half the loop span's weight so the two read as nested rather + // than as a second loop. Drawn before the marker bars so the bars stay on top. + if (m.hasLoop && m.crossfade > 0) { + const int fx = frameToX(overlay, frames, m.loopStart - m.crossfade); + const int lx = frameToX(overlay, frames, m.loopStart); + if (lx > fx) { + LICE_FillRect(bmp, fx, overlayRect.y, lx - fx, overlayRect.height, + toLice(roleColor(kRoleLoopMarker)), + static_cast(kLoopSpanFillAlpha) * 0.5f, 0); + } + } const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; for (int i = 0; i < 3; ++i) { @@ -107,6 +118,15 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, toLice(roleColor(markerRoles[i])), alpha, 0); } + // The crossfade's grab tab. Only offered with a loop set, matching the hit-test, and it + // is the whole affordance for a zero-length fade — nothing else marks where it sits. + if (m.hasLoop) { + const Rect tab = markerHandleRect(overlay, frames, m.loopStart - m.crossfade); + if (!tab.empty()) { + LICE_FillRect(bmp, tab.x, tab.y, tab.width, tab.height, + toLice(roleColor(kRoleLoopMarker)), 1.0f, 0); + } + } paintEnvelopeOverlay(bmp, overlay, frames); } diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index f6b4dd2..1e7ce3c 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -17,6 +17,7 @@ #include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key) #include "core/util/file_bytes.h" // shared whole-file loader #include "ext_keys.h" +#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span) #include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter) #include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reasampler_processor.h" @@ -31,6 +32,8 @@ using capture::WavLayout; using capture::extractFloatFrames; using capture::parseWavLayout; using capture::resolveBankFile; +using instrument::engine::loop::LoopBounds; +using instrument::engine::loop::defaultLoopBounds; using instrument::ui::nameMatchesQuery; using ui::ThumbnailKey; using ui::thumbnailKeyString; @@ -160,10 +163,12 @@ void ReaSamplerEditor::loadSelection(const std::string& id) { // loaded, so a load swaps the sound and keeps the settings. The three CAPTURE-ANCHORED // overrides are: a root, a loop span and a start frame all name positions in the // OUTGOING capture and mean nothing in the new one, so they clear and the new capture - // plays from its own bank intrinsics. + // plays from its own bank intrinsics. The loop crossfade goes with the span it belongs + // to — it is a length in the outgoing capture's frames. selectedId_ = id; params_.rootOverride.reset(); params_.loopOverride.reset(); + params_.loopCrossfadeFrames = 0; params_.startPoint.reset(); commitAndReload(); } @@ -196,10 +201,17 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram m.loopEnd = params_.loopOverride->end; } if (params_.startPoint) m.start = *params_.startPoint; - // Default an unset loop's end to the sample length so the loop markers have somewhere sane - // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; - // the markers are still drawn (drag one to CREATE a loop). - if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + m.crossfade = params_.loopCrossfadeFrames; + // A collapsed or inverted span is the OFF state (the engine refuses it either way), so + // park the handles on the shared default rather than leaving them stacked on each other + // where neither could be grabbed apart again. The markers are still drawn at 'no loop' + // weight — drag one to CREATE a loop. + if (!m.hasLoop || m.loopEnd <= m.loopStart) { + m.hasLoop = false; + const LoopBounds d = defaultLoopBounds(frames); + m.loopStart = d.start; + m.loopEnd = d.end; + } return m; } @@ -207,10 +219,13 @@ void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) { // Write the edited markers into the parameter set as the loop/start override. The bank // intrinsic is never written (read-only bank consumer). SampleLoop loop; - loop.hasLoop = m.hasLoop; + // Collapsing the span onto itself is the OFF gesture — record it as such so the next + // pickedMarkers re-offers the default handles instead of two coincident ones. + loop.hasLoop = m.hasLoop && m.loopEnd > m.loopStart; loop.start = m.loopStart; loop.end = m.loopEnd; params_.loopOverride = loop; + params_.loopCrossfadeFrames = m.crossfade; params_.startPoint = m.start; } diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index ab2f594..376a5a4 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -93,8 +93,11 @@ private: using ParamControl = instrument::ui::DeckParam; // The waveform markers on the waveform band: start-point + the sustain loop's two ends, - // in draw + hit order. - enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 }; + // in draw + hit order, then the crossfade handle. The crossfade is NOT part of the + // full-height column hit-test — it answers only in its top-strip handle (waveform_view's + // markerHandleRect), because at a zero fade it sits exactly on the loop start. + enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kLoopXfade = 3, + kCount = 4 }; // The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index` // disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when @@ -291,12 +294,15 @@ private: std::string samplePathFor(const std::string& sampleId) const; // The effective loop + start markers for the loaded capture: the parameter set's - // override when one is set, else the bank's loop intrinsic / frame 0. Absent loop -> - // loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty. + // override when one is set, else the bank's loop intrinsic / frame 0. With no loop set, + // the loop handles park on loop_span's defaultLoopBounds so both stay grabbable — the + // frame-0 default they replace put loopStart under the start marker, where nothing could + // reach it. struct SetupMarkers { std::int64_t start = 0; std::int64_t loopStart = 0; std::int64_t loopEnd = 0; + std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames; handle at loopStart - this bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance) }; SetupMarkers pickedMarkers(std::int64_t frames) const; @@ -305,6 +311,10 @@ private: // callers decide live-drag vs final commit. void applyMarkers(const SetupMarkers& m); + // Arms a waveform-marker drag: the grabbed marker plus the snapshot the pixel-delta + // resolver and the inter-marker clamps measure from. + void beginMarkerDrag(WaveMarker which, const SetupMarkers& m, std::int64_t frames, int x); + // Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD // pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames. diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 7d8c7c2..813428a 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -418,6 +418,7 @@ static void testGoldenFullBlobFixture() { loopA.start = 1000; loopA.end = 5000; in.params.loopOverride = loopA; + in.params.loopCrossfadeFrames = 256; in.params.startPoint = 250; in.params.keyTrack = 0.5; in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( @@ -449,7 +450,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0a,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0b,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -504,6 +505,8 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD hold 1.0 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD att curve 1.0 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0 + // --- payload v11 loop-crossfade tail --- + 0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256 }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -551,13 +554,16 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 10); + CHECK(kParamsPayloadVersion == 11); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes - // stay independent, so a future envelope field cannot collide with either on one number. + // The filter, staged-curve and loop tails rode PAYLOAD bumps, not envelope ones — the two + // axes stay independent, so a future envelope field cannot collide with any of them on one + // number. CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion); + CHECK(kParamsLoopVersion > kParamsCurveVersion); + CHECK(kParamsPayloadVersion == kParamsLoopVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -701,6 +707,120 @@ static void testNonFiniteAhdSecondsLiftToZero() { CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0); } +// --- The loop tail (payload v11) --------------------------------------------- + +// The loop span and its crossfade survive a save/reload intact, alongside the two overrides +// that share the record's head — a codec that read the crossfade into a neighbouring int64 +// fails here rather than at the ear. +static void testLoopSpanAndCrossfadeRoundTrip() { + ComponentState in; + in.selectionId = "pad"; + SampleLoop lp; + lp.hasLoop = true; + lp.start = 4096; + lp.end = 65536; + in.params.loopOverride = lp; + in.params.loopCrossfadeFrames = 1024; + in.params.startPoint = 512; + in.params.rootOverride = 55; + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop); + CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096); + CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536); + CHECK(out.params.loopCrossfadeFrames == 1024); + CHECK(out.params.startPoint && *out.params.startPoint == 512); + CHECK(out.params.rootOverride && *out.params.rootOverride == 55); +} + +// A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it +// at the wire keeps the parameter set the editor reads back sane. +static void testNegativeCrossfadeOnTheWireLiftsToZero() { + ComponentState in; + in.selectionId = "pad"; + in.params.loopCrossfadeFrames = -4096; + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.loopCrossfadeFrames == 0); +} + +// Payload tails are strict SUFFIXES by construction, so a vN blob IS the current writer's +// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs +// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped +// being a pure suffix, these would decode as garbage instead of as the documented lift. +static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64 +static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles +static const std::size_t kFilterTailBytes = + 1 + 4 * 8 + 1 + 3 * 8 + 5 * 8 + (4 + 2 * 2 * 8); // v9: the filter block + its 2-pt curve + +static std::vector payloadDowngradedTo(const ComponentState& state, + std::uint32_t pv, std::size_t cutBytes) { + std::vector bytes = serializeComponentState(state); + bool stamped = false; + for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) { + const std::uint32_t m = static_cast(bytes[i]) | + (static_cast(bytes[i + 1]) << 8) | + (static_cast(bytes[i + 2]) << 16) | + (static_cast(bytes[i + 3]) << 24); + if (m != kParamsFormatMarker) continue; + // Guard the naive marker scan: a false positive inside payload data would not be + // sitting in front of the CURRENT version. + CHECK(bytes[i + 4] == static_cast(kParamsPayloadVersion)); + bytes[i + 4] = static_cast(pv); + stamped = true; + break; + } + CHECK(stamped); + CHECK(bytes.size() > cutBytes); + bytes.resize(bytes.size() - cutBytes); + return bytes; +} + +// A project saved before this change reopens sounding identical: its loop span still applies +// and its seam is still hard, at EVERY prior single-record version. +static void testPriorPayloadVersionsLiftToAHardSeam() { + ComponentState in; + in.selectionId = "pad"; + SampleLoop lp; + lp.hasLoop = true; + lp.start = 2000; + lp.end = 9000; + in.params.loopOverride = lp; + in.params.startPoint = 128; + in.params.keyTrack = 0.5; + in.params.play.adsr.releaseSeconds = 0.25; + // Set on the in-state only so a v10 lift can be checked to keep it and a v9 lift to drop + // it — proving the cuts land where the ladder says they do. + in.params.play.adsr.attackCurve = 4.0; + in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11 + + struct Case { + std::uint32_t pv; + std::size_t cut; + bool keepsCurveTail; + }; + const Case cases[] = { + {10, kLoopTailBytes, true}, + {9, kLoopTailBytes + kCurveTailBytes, false}, + {8, kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false}, + }; + for (const Case& c : cases) { + const ComponentState out = + deserializeComponentState(payloadDowngradedTo(in, c.pv, c.cut), 48000.0); + // The span itself has been in the format since v2 and must survive untouched. + CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop); + CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000); + CHECK(out.params.loopOverride && out.params.loopOverride->end == 9000); + CHECK(out.params.startPoint && *out.params.startPoint == 128); + CHECK(out.params.keyTrack == 0.5); + CHECK(out.params.play.adsr.releaseSeconds == 0.25); + // The documented pre-change behaviour: a hard seam. + CHECK(out.params.loopCrossfadeFrames == 0); + // And the cut landed on the tail boundary the ladder claims, not somewhere inside it. + CHECK(out.params.play.adsr.attackCurve == + (c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral)); + } +} + // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of // the payload — the self-describing property every legacy branch depends on. Asserted // against the semantic constants, not literals. @@ -1270,6 +1390,9 @@ int main() { testGoldenFullBlobFixture(); testDefaultStateRoundTripsToDefaults(); testEnvelopePrefixBytesFrozen(); + testLoopSpanAndCrossfadeRoundTrip(); + testNegativeCrossfadeOnTheWireLiftsToZero(); + testPriorPayloadVersionsLiftToAHardSeam(); testWriterEmitsCurrentPayloadVersion(); testSingleZoneMigrationIsLossless(); testMigratedFadeContourTracksTheRetiredEqualPowerShape(); diff --git a/tests/test_loop_span.cpp b/tests/test_loop_span.cpp new file mode 100644 index 0000000..092d052 --- /dev/null +++ b/tests/test_loop_span.cpp @@ -0,0 +1,167 @@ +// Standalone tests for reasampler::instrument::engine::loop — no VST3, no REAPER, no test +// framework. Covers the loop's validity/clamp rule, the pre-seam crossfade geometry, and the +// editor's default handle placement. + +#include "../src/core/instrument/engine/loop/loop_span.h" + +#include + +using namespace reasampler; +using namespace reasampler::instrument::engine::loop; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static SampleLoop span(std::int64_t start, std::int64_t end, bool has = true) { + SampleLoop l; + l.hasLoop = has; + l.start = start; + l.end = end; + return l; +} + +// --- Validity ----------------------------------------------------------------- + +static void testValidGateLoopResolvesToItsOwnSpan() { + const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, /*gateMode=*/true); + CHECK(lp.active); + CHECK(lp.start == 100); + CHECK(lp.end == 400); + CHECK(lp.length == 300); + CHECK(lp.crossfade == 0); +} + +static void testTriggerModeNeverLoops() { + const ResolvedLoop lp = resolveLoop(span(100, 400), 32, 1000, /*gateMode=*/false); + CHECK(!lp.active); + CHECK(lp.length == 0); + CHECK(lp.crossfade == 0); +} + +static void testUnsetLoopIsInactive() { + const ResolvedLoop lp = resolveLoop(span(100, 400, /*has=*/false), 32, 1000, true); + CHECK(!lp.active); +} + +// An inverted, empty, negative, or out-of-range span is REFUSED rather than repaired: a +// wrong loop the user can hear beats a wrong loop the engine invented, and refusing is what +// keeps the read path from indexing outside the PCM. +static void testMalformedSpansAreRefusedNotRepaired() { + CHECK(!resolveLoop(span(400, 100), 0, 1000, true).active); // inverted + CHECK(!resolveLoop(span(200, 200), 0, 1000, true).active); // empty + CHECK(!resolveLoop(span(-5, 400), 0, 1000, true).active); // negative start + CHECK(!resolveLoop(span(100, 1001), 0, 1000, true).active); // end past the PCM + CHECK(resolveLoop(span(100, 1000), 0, 1000, true).active); // end AT the PCM is fine +} + +// --- Crossfade clamping ------------------------------------------------------- + +static void testCrossfadeClampsToTheMaterialAheadOfTheLoop() { + // The incoming tap reads [start - xf, start), so the fade cannot outrun `start`. + const ResolvedLoop lp = resolveLoop(span(50, 400), 500, 1000, true); + CHECK(lp.active); + CHECK(lp.crossfade == 50); + CHECK(lp.fadeBegin == 350.0); +} + +static void testCrossfadeClampsToTheLoopLength() { + const ResolvedLoop lp = resolveLoop(span(500, 600), 400, 1000, true); + CHECK(lp.active); + CHECK(lp.crossfade == 100); // loop length, not the 400 asked for or the 500 before it +} + +static void testLoopAtFrameZeroGetsNoCrossfade() { + const ResolvedLoop lp = resolveLoop(span(0, 400), 64, 1000, true); + CHECK(lp.active); + CHECK(lp.crossfade == 0); // nothing precedes the loop to fade in from + CHECK(lp.fadeInv == 0.0); +} + +static void testNegativeCrossfadeIsZero() { + const ResolvedLoop lp = resolveLoop(span(100, 400), -20, 1000, true); + CHECK(lp.active); + CHECK(lp.crossfade == 0); +} + +// --- Crossfade weight --------------------------------------------------------- + +static void testZeroCrossfadeWeighsNothingAnywhere() { + const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, true); + CHECK(crossfadeWeight(lp, 100.0) == 0.0); + CHECK(crossfadeWeight(lp, 399.0) == 0.0); + CHECK(crossfadeWeight(lp, 399.999) == 0.0); +} + +// The weight rises from exactly 0 at the region's start to exactly 1 at the loop end, which +// is what makes the seam continuous: at `end` the incoming tap has reached `start`, and the +// wrap puts the head there. +static void testWeightRunsZeroToOneAcrossTheFadeRegion() { + const ResolvedLoop lp = resolveLoop(span(100, 400), 100, 1000, true); + CHECK(lp.crossfade == 100); + CHECK(lp.fadeBegin == 300.0); + CHECK(crossfadeWeight(lp, 299.0) == 0.0); + CHECK(crossfadeWeight(lp, 300.0) == 0.0); + CHECK(crossfadeWeight(lp, 350.0) == 0.5); + CHECK(crossfadeWeight(lp, 375.0) == 0.75); + CHECK(crossfadeWeight(lp, 400.0) == 1.0); +} + +static void testWeightIsMonotoneAndBoundedAcrossTheRegion() { + const ResolvedLoop lp = resolveLoop(span(100, 400), 60, 1000, true); + double prev = -1.0; + for (int i = 0; i <= 400; ++i) { + const double pos = 300.0 + static_cast(i) * 0.25; // sweeps 300..400 + const double w = crossfadeWeight(lp, pos); + CHECK(w >= prev); + CHECK(w >= 0.0 && w <= 1.0); + prev = w; + } + CHECK(prev == 1.0); +} + +// The ceiling is a belt for an unwrapped caller: without it the linear ramp would extrapolate +// past the incoming tap and amplify it. +static void testWeightSaturatesPastTheLoopEnd() { + const ResolvedLoop lp = resolveLoop(span(100, 400), 50, 1000, true); + CHECK(crossfadeWeight(lp, 500.0) == 1.0); +} + +// --- Default handle placement ------------------------------------------------- + +static void testDefaultBoundsSitInTheLastQuarterAndClearFrameZero() { + const LoopBounds d = defaultLoopBounds(1000); + CHECK(d.start == 750); + CHECK(d.end == 1000); + CHECK(d.start > 0); // the whole point: it does not land under the start marker + CHECK(defaultLoopBounds(0).start == 0 && defaultLoopBounds(0).end == 0); + CHECK(defaultLoopBounds(-5).end == 0); +} + +// A default span is itself a valid loop, so the handles a user is offered describe a loop the +// engine will actually accept. +static void testDefaultBoundsResolveActive() { + const LoopBounds d = defaultLoopBounds(888); + const ResolvedLoop lp = resolveLoop(span(d.start, d.end), 0, 888, true); + CHECK(lp.active); + CHECK(lp.start == d.start && lp.end == d.end); +} + +int main() { + testValidGateLoopResolvesToItsOwnSpan(); + testTriggerModeNeverLoops(); + testUnsetLoopIsInactive(); + testMalformedSpansAreRefusedNotRepaired(); + testCrossfadeClampsToTheMaterialAheadOfTheLoop(); + testCrossfadeClampsToTheLoopLength(); + testLoopAtFrameZeroGetsNoCrossfade(); + testNegativeCrossfadeIsZero(); + testZeroCrossfadeWeighsNothingAnywhere(); + testWeightRunsZeroToOneAcrossTheFadeRegion(); + testWeightIsMonotoneAndBoundedAcrossTheRegion(); + testWeightSaturatesPastTheLoopEnd(); + testDefaultBoundsSitInTheLastQuarterAndClearFrameZero(); + testDefaultBoundsResolveActive(); + if (g_fail == 0) std::printf("loop_span_tests: all passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4b24e0d..c67ebb9 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -712,6 +712,251 @@ static void testStartAfterLoopEndWrapsIntoLoop() { } } +// --------------------------------------------------------------------------- +// Gate loop sustain: sample-exact wrapping, the crossfaded seam, and release out. +// --------------------------------------------------------------------------- + +// A sample whose every frame carries its own index scaled down, so an observed output value +// names the exact source frame it came from — which is what makes "sample-exact" assertable +// rather than merely plausible. +static SampleData indexSample(std::size_t frames, int rootNote = 60) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(i) * 0.001f; + } + s.rootNote = rootNote; + return s; +} + +// The source frame an output value names, inverted from indexSample's encoding. +static double sourceFrameOf(double out) { return out * 1000.0; } + +static void testLoopReadWrapsSampleExactOverManyCycles() { + // Loop [40, 60) over a 100-frame index sample: the head must walk 40..59 and jump back to + // exactly 40, cycle after cycle, with nothing skipped or repeated at the seam. + SampleData s = indexSample(100); + s.loop.hasLoop = true; + s.loop.start = 40; + s.loop.end = 60; + s.play.adsr = flatAdsr(); + + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); // 8 full cycles past the loop entry + + CHECK(eng.activeVoiceCount() == 1); + for (std::size_t i = 0; i < out.size(); ++i) { + // Output frame i reads source frame i while i < 60, then wraps by 20 each cycle. + const std::int64_t expected = i < 60 ? static_cast(i) + : 40 + ((static_cast(i) - 60) % 20); + CHECK(approx(sourceFrameOf(out[i]), static_cast(expected), 1e-3)); + } +} + +static void testZeroCrossfadeLeavesTheSeamHard() { + // With no fade dialled, the frame at the loop end and the frame after it are the raw + // source frames — the step is the whole point of the default, and the whole thing a + // nonzero fade has to smooth. + SampleData s = indexSample(100); + s.loop.hasLoop = true; + s.loop.start = 40; + s.loop.end = 60; + s.loopCrossfadeFrames = 0; + s.play.adsr = flatAdsr(); + + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 80); + + CHECK(approx(sourceFrameOf(out[59]), 59.0, 1e-3)); + CHECK(approx(sourceFrameOf(out[60]), 40.0, 1e-3)); // hard jump, no blend +} + +// The output at output-frame n, for loop [40,60) over the index sample under fade length xf. +static double loopedFrameValue(std::int64_t xf, std::size_t n) { + SampleData s = indexSample(100); + s.loop.hasLoop = true; + s.loop.start = 40; + s.loop.end = 60; + s.loopCrossfadeFrames = xf; + s.play.adsr = flatAdsr(); + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 80); + return sourceFrameOf(out[n]); +} + +static void testCrossfadeBlendsMonotonelyAcrossTheRegion() { + // Loop [40, 60) with an 8-frame pre-seam fade: over output frames 52..59 the read blends + // from source frame n toward source frame n-20 (the same head one loop length back). The + // region ENTRY is exactly continuous — frame 52 carries weight 0 — and every frame in it + // is the exact linear blend, falling monotonically. + SampleData s = indexSample(100); + s.loop.hasLoop = true; + s.loop.start = 40; + s.loop.end = 60; + s.loopCrossfadeFrames = 8; + s.play.adsr = flatAdsr(); + + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 80); + + CHECK(approx(sourceFrameOf(out[51]), 51.0, 1e-3)); + CHECK(approx(sourceFrameOf(out[52]), 52.0, 1e-3)); // weight 0 at the region edge + for (int n = 52; n < 60; ++n) { + const double w = static_cast(n - 52) / 8.0; + const double want = static_cast(n) * (1.0 - w) + static_cast(n - 20) * w; + CHECK(approx(sourceFrameOf(out[static_cast(n)]), want, 1e-3)); + } + for (int n = 53; n < 60; ++n) { + CHECK(out[static_cast(n)] < out[static_cast(n - 1)]); + } +} + +// What a longer fade actually buys, measured rather than asserted by adjective. The last +// rendered frame before the wrap carries weight (xf-1)/xf, not 1 — the weight only reaches 1 +// AT `end`, a position no frame lands on — so a residual step of (seam step)/xf survives. +// It shrinks in exact proportion to the fade length, which is the property that makes the +// parameter meaningful and the seam inaudible at any musically useful setting. +static void testSeamStepShrinksInProportionToTheFadeLength() { + auto seamStep = [](std::int64_t xf) { + return std::fabs(loopedFrameValue(xf, 60) - loopedFrameValue(xf, 59)); + }; + const double hard = seamStep(0); + CHECK(approx(hard, 19.0, 1e-3)); // 59 -> 40, the whole loop length less one frame + CHECK(approx(seamStep(4), 4.0, 1e-3)); + CHECK(approx(seamStep(8), 1.5, 1e-3)); + CHECK(approx(seamStep(16), 0.25, 1e-3)); + // Each doubling roughly halves it, and even the shortest fade tested is a quarter of the + // hard seam. + CHECK(seamStep(4) < hard * 0.25); + CHECK(seamStep(8) < seamStep(4) * 0.5); + CHECK(seamStep(16) < seamStep(8) * 0.5); +} + +static void testCrossfadeLengthFollowsItsParameter() { + // A longer fade starts earlier and nowhere else: the region begin is end - crossfade, so + // doubling the parameter doubles the number of blended frames. + auto firstFadedFrame = [](std::int64_t xf) { + SampleData s = indexSample(100); + s.loop.hasLoop = true; + s.loop.start = 40; + s.loop.end = 60; + s.loopCrossfadeFrames = xf; + s.play.adsr = flatAdsr(); + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 70); + for (int n = 40; n < 60; ++n) { + const double got = sourceFrameOf(out[static_cast(n)]); + if (!approx(got, static_cast(n), 1e-3)) return n; + } + return 60; + }; + CHECK(firstFadedFrame(0) == 60); // never diverges from the raw read + CHECK(firstFadedFrame(4) == 57); // region [56,60); frame 56 carries weight 0 + CHECK(firstFadedFrame(8) == 53); // region [52,60) + CHECK(firstFadedFrame(16) == 45); // region [44,60) +} + +// The fade cannot read before frame 0, so a loop starting at 0 gets none — silently clamped +// rather than reading out of bounds or refusing the loop outright. +static void testCrossfadeIsSuppressedForALoopAtFrameZero() { + SampleData s = indexSample(100); + s.loop.hasLoop = true; + s.loop.start = 0; + s.loop.end = 20; + s.loopCrossfadeFrames = 16; + s.play.adsr = flatAdsr(); + + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 60); + + CHECK(eng.activeVoiceCount() == 1); + for (int n = 0; n < 20; ++n) { + CHECK(approx(sourceFrameOf(out[static_cast(n)]), + static_cast(n), 1e-3)); + } +} + +static void testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice() { + // The loop is the SUSTAIN: held, the voice never ends; released, it must leave the + // sustain level down the release ramp and free itself — not keep cycling forever. + SampleData s = dcSample(60, 60); // flat 1.0 so output IS the envelope + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + s.loopCrossfadeFrames = 4; + AdsrParams a = flatAdsr(); + a.releaseFrames = 32; + s.play.adsr = a; + + VoiceEngine eng(1, s); + eng.noteOn(60, 127); + std::vector held; + eng.render(held, 500); // far past the sample end + CHECK(eng.activeVoiceCount() == 1); + CHECK(approx(held.back(), 1.0, 1e-4)); // still at sustain, still looping + + eng.noteOff(60); + std::vector tail; + eng.render(tail, 16); + // Mid-release: audibly decaying, not yet done. + CHECK(tail.back() < 0.75 && tail.back() > 0.0); + CHECK(eng.activeVoiceCount() == 1); + + std::vector rest; + eng.render(rest, 64); + CHECK(eng.activeVoiceCount() == 0); + CHECK(approx(rest.back(), 0.0, 1e-6)); +} + +// Preserve's contract is LOOP THE SOURCE, SHIFT THE OUTPUT: the loop points name source +// frames, so a transposed note loops the same source span and keeps sounding at level. If the +// span were treated as an output-domain fact, an up-shifted voice would outrun it, freeze its +// shifter tail and decay. Run with and without a crossfade, since the fade is applied to the +// SOURCE feed ahead of the shifter and the ring prime walks the same blend. +static void testPreserveLoopsTheSourceSpanAtEveryTransposition() { + for (std::int64_t xf : {std::int64_t{0}, std::int64_t{16}}) { + for (int note : {48, 60, 72}) { + SampleData s; + s.frames.resize(200, 0.0f); + for (int i = 60; i < 120; ++i) s.frames[i] = 0.5f; // the loop body + its run-in + s.rootNote = 60; + s.sampleRate = 48000; + s.loop.hasLoop = true; + s.loop.start = 80; + s.loop.end = 120; + s.loopCrossfadeFrames = xf; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + + VoiceEngine eng(1, s); + eng.noteOn(note, 127); + std::vector out; + eng.render(out, 4000); // 20x the sample length + // The source span is finite; only the loop can keep a voice alive this long, and + // it does so at every transposition because the span is a source-frame fact. + CHECK(eng.activeVoiceCount() == 1); + // And it is still delivering the loop body, not a decaying frozen tail. The body + // and its run-in are one constant, so the shifter's splices reproduce it whatever + // the shift ratio and whatever the fade blends. + double sum = 0.0; + for (std::size_t i = out.size() - 200; i < out.size(); ++i) sum += out[i]; + CHECK(approx(sum / 200.0, 0.5, 0.05)); + } + } +} + // --------------------------------------------------------------------------- // velocity -> volume. // --------------------------------------------------------------------------- @@ -2562,6 +2807,14 @@ int main() { testStartFrameOutOfRangeClampsToZero(); testStartFrameWithLoop(); testStartAfterLoopEndWrapsIntoLoop(); + testLoopReadWrapsSampleExactOverManyCycles(); + testZeroCrossfadeLeavesTheSeamHard(); + testCrossfadeBlendsMonotonelyAcrossTheRegion(); + testSeamStepShrinksInProportionToTheFadeLength(); + testCrossfadeLengthFollowsItsParameter(); + testCrossfadeIsSuppressedForALoopAtFrameZero(); + testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice(); + testPreserveLoopsTheSourceSpanAtEveryTransposition(); testVelocityDefaultCurveIsFlatUnity(); testVelocityLinearCurveReproducesRamp(); testVelocityShapedCurveDrivesGain(); diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index e4cb1c6..8e988a3 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -5,6 +5,7 @@ // // Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width); // markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection); +// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable); // resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width // no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, // no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked @@ -309,6 +310,57 @@ static void testMarkerGrabInMonoSpansTheBand() { CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() + 5) == -1); } +// --- The marker grab handle ---------------------------------------------------- + +static void testMarkerHandleIsATopStripCentredOnTheMarker() { + const Rect a = wideArea(); + const int mx = frameToX(overlayOf(a), 1000, 250); + const Rect h = markerHandleRect(overlayOf(a), 1000, 250); + CHECK(h.x == mx - kMarkerHandleHalfWidth); + CHECK(h.right() == mx + kMarkerHandleHalfWidth + 1); + CHECK(h.y == a.y); + CHECK(h.height == kMarkerHandleHeight); + CHECK(contains(h, mx, a.y)); + CHECK(contains(h, mx, a.y + kMarkerHandleHeight - 1)); + CHECK(!contains(h, mx, a.y + kMarkerHandleHeight)); // below the strip is the column's +} + +// The whole reason the handle exists: two markers that share a frame both stay reachable — +// markerAtPoint gives its full-height column to the first in draw order, and the handle owns +// the strip above. Without the split, the loser could never be dragged apart again. +static void testCoincidentMarkersStayIndependentlyGrabbable() { + const Rect a = wideArea(); + const std::int64_t markers[2] = {250, 250}; + const int mx = frameToX(overlayOf(a), 1000, 250); + // The column resolves to the first marker at every height, including the top strip. + CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.y) == 0); + CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.bottom() - 1) == 0); + // The handle, asked first, resolves the second one in that same top strip. + CHECK(contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.y)); + CHECK(!contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.bottom() - 1)); +} + +static void testMarkerHandleClipsIntoTheArea() { + const Rect a = wideArea(); + // At the last frame the marker maps to right(); an unclipped tab would claim pixels + // outside the band the caller already hit-tested. + const Rect hi = markerHandleRect(overlayOf(a), 1000, 1000); + CHECK(hi.right() == a.right()); + CHECK(!contains(hi, a.right(), a.y)); + CHECK(contains(hi, a.right() - 1, a.y)); + // And at frame 0 it cannot reach left of the band. + const Rect lo = markerHandleRect(overlayOf(a), 1000, 0); + CHECK(lo.x == a.x); + CHECK(!contains(lo, a.x - 1, a.y)); +} + +static void testMarkerHandleOnDegenerateAreas() { + CHECK(markerHandleRect(overlayOf(Rect{}), 1000, 0).empty()); + // A band shorter than the strip yields a handle the height of the band, never taller. + const Rect thin = Rect{0, 0, 100, 4}; + CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4); +} + // --- Per-lane envelope content ------------------------------------------------- static void testAsymmetricStereoLanesCarryDifferentContent() { @@ -379,6 +431,11 @@ int main() { testMarkerGrabReachesTheLowerStereoLane(); testMarkerGrabInMonoSpansTheBand(); + testMarkerHandleIsATopStripCentredOnTheMarker(); + testCoincidentMarkersStayIndependentlyGrabbable(); + testMarkerHandleClipsIntoTheArea(); + testMarkerHandleOnDegenerateAreas(); + testAsymmetricStereoLanesCarryDifferentContent(); testLaneEnvelopeRejectsOutOfRangeLane();