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

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+5 -3
View File
@@ -115,7 +115,8 @@ start point, Gate has modifiable loop points too. In addition to amp env, there
pitch envelope/curve (AD?) which is off by default."* pitch envelope/curve (AD?) which is off by default."*
- **Gate — classic held note.** Note-on enters the amp envelope; note-off enters - **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 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 note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
back-compat degenerate. 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. - `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.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved). - `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. - `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/` ### `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`. - `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. - `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. - `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. - `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_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. - `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. - `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. - **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. - `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. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
+5 -1
View File
@@ -16,6 +16,10 @@ reasampler_test(master_gain LINK master_gain)
# Declared before sampler_core because the voice now runs one per sounding note. # Declared before sampler_core because the voice now runs one per sounding note.
add_subdirectory(filter) 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 # 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. # engine — the block is a plain value the voice observes, not a thing the engine owns.
reasampler_pure_library(live_params reasampler_pure_library(live_params
@@ -28,7 +32,7 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing. # boundary costs the hot path nothing.
reasampler_pure_library(sampler_core reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp 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 # 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. # or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core) reasampler_test(sampler_core LINK sampler_core)
+60
View File
@@ -0,0 +1,60 @@
# src/core/instrument/engine/loop — the sustain loop's span rule
## Scope
One pure module, `loop_span`: the ONE fold that turns a stored `SampleLoop` + crossfade
length into the `ResolvedLoop` the voice's read path wraps on, plus the editor's default
handle placement for a capture with no loop. No REAPER, no VST3, no allocation, no I/O.
Everything lives in `reasampler::instrument::engine::loop`.
`resolveLoop` is cold — called once per note-on and by the editor. `crossfadeWeight` is
header-inline because it is evaluated per voice per sample.
## Invariants
### The crossfade is PRE-SEAM, and that is what makes it one tap
The fade runs over the last `crossfade` frames before `end`, blending the material running
into `end` toward the material running into `start`. The incoming material is the same read
head one loop length earlier, so the second tap is `pos - length` — no second position to
advance, no second wrap rule, no state. At `end` the incoming tap has arrived at `start`,
which is exactly where the wrap puts the head, so the seam is continuous by construction
rather than by a fade that merely hides it.
The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at frame 0 has no
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
rather than silently reading before the buffer.
### The seam does not close to zero, it closes as 1/crossfade
The weight reaches 1 only AT `end` — a position no rendered frame lands on — so the last
frame before the wrap carries `(xf-1)/xf` and a residual step of `(seam step)/xf` survives.
Measured on a source-frame ramp with a 19-frame seam: 4.0 at `xf = 4`, 1.5 at 8, 0.25 at 16
(`sampler_core_tests`). It is proportional and therefore inaudible at any musically useful
setting; a claim that the crossfade makes the seam *exactly* continuous is wrong in the
discrete domain and a test asserting it will fail.
### Linear, not equal-power
The two taps are one loop length apart in the same material and are usually well correlated,
where an equal-power pair bulges. Linear also costs a subtract and a multiply on a path that
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.
### An invalid span is refused, never repaired
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
`active == false`, so the note plays straight through. Repairing a corrupt span into a
plausible one would make a wrong loop audible and a bug invisible; the crossfade length is
the one field that IS clamped rather than refused, because its bound is a property of the
loop it sits in rather than of the user's intent.
## Gotchas
- **`crossfadeWeight` assumes its argument is already wrapped** into `[start, end)`. The
ceiling at 1.0 is a belt against an unwrapped caller, not permission to skip the wrap —
an unwrapped position would otherwise extrapolate past the incoming tap.
- **`defaultLoopBounds` is a UI default living in an engine module** on purpose: the span the
user is offered and the span `resolveLoop` will accept have to be one definition, and the
previous frame-0 default put the loop-start handle underneath the start marker where no
grab could reach it.
@@ -0,0 +1,7 @@
# The loop's validity rule and crossfade geometry. Links peaks only (via play_params' own
# SampleLoop) — deliberately not the voice engine: the resolve is a fold over plain values,
# which is what lets the editor share it without pulling the engine in.
reasampler_pure_library(loop_span
SOURCES loop_span.cpp
LINK PUBLIC peaks filter velocity_curve curve_law)
reasampler_test(loop_span LINK loop_span)
@@ -0,0 +1,37 @@
// loop_span.cpp — see loop_span.h. Pure math; no host types.
#include "core/instrument/engine/loop/loop_span.h"
namespace reasampler::instrument::engine::loop {
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount, bool gateMode) {
ResolvedLoop out;
// Trigger is a one-shot by definition, so the loop is not merely unused there — it is
// absent, and the read path branches on this one flag.
if (!gateMode || !loop.hasLoop) return out;
if (loop.start < 0 || loop.end <= loop.start || loop.end > frameCount) return out;
out.active = true;
out.start = loop.start;
out.end = loop.end;
out.length = loop.end - loop.start;
// The incoming tap reads at `pos - length`, i.e. over [start - crossfade, start) — so the
// fade cannot outrun the material ahead of the loop, nor the loop itself.
std::int64_t xf = crossfadeFrames;
if (xf < 0) xf = 0;
if (xf > out.start) xf = out.start;
if (xf > out.length) xf = out.length;
out.crossfade = xf;
out.fadeBegin = static_cast<double>(out.end - xf);
out.fadeInv = xf > 0 ? 1.0 / static_cast<double>(xf) : 0.0;
return out;
}
LoopBounds defaultLoopBounds(std::int64_t frameCount) {
if (frameCount <= 0) return LoopBounds{};
return LoopBounds{frameCount - frameCount / 4, frameCount};
}
} // namespace reasampler::instrument::engine::loop
@@ -0,0 +1,54 @@
#pragma once
// loop_span.h — the sustain loop's ONE validity/clamp rule plus its pre-seam crossfade
// geometry. The resolve is cold (note-on, editor); crossfadeWeight is header-inline because
// it sits on the per-voice-per-sample read.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // SampleLoop
namespace reasampler::instrument::engine::loop {
// A sustain loop folded against one capture: validity, geometry, and the clamped crossfade.
// `active == false` leaves every other field zero, so a caller can wrap on the flag alone.
//
// The fade is PRE-SEAM and its incoming tap is the same read head one loop length earlier —
// `pos - length`, no second position to advance. See CLAUDE.md for why that shape, and for
// the `crossfade <= start` bound it forces.
struct ResolvedLoop {
bool active = false;
std::int64_t start = 0;
std::int64_t end = 0; // half-open
std::int64_t length = 0; // end - start
std::int64_t crossfade = 0; // source frames; 0 = hard seam
double fadeBegin = 0.0; // end - crossfade
double fadeInv = 0.0; // 1 / crossfade; 0 when crossfade == 0
};
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
// could not honour — a non-Gate mode, an unset loop, an inverted or empty span, a span
// reaching outside the PCM — by returning an inactive result rather than a repaired one, so a
// corrupt span silently plays through instead of reading out of bounds.
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount, bool gateMode);
// Weight of the INCOMING (pre-loop-start) tap at source position `pos`: 0 before the fade
// region, rising linearly to 1 at `end`. `pos` must already be wrapped into [start, end) —
// the ceiling is a belt for a caller that has not wrapped yet, not a licence to skip it.
inline double crossfadeWeight(const ResolvedLoop& lp, double pos) {
if (lp.crossfade <= 0) return 0.0;
const double d = pos - lp.fadeBegin;
if (d <= 0.0) return 0.0;
return d < static_cast<double>(lp.crossfade) ? d * lp.fadeInv : 1.0;
}
// Where the editor parks the loop handles for a capture that has none — the last quarter,
// where a sustain loop actually goes. Lives here, next to the validity rule, so the span a
// user is offered and the span the engine will accept are one definition.
struct LoopBounds {
std::int64_t start = 0;
std::int64_t end = 0;
};
LoopBounds defaultLoopBounds(std::int64_t frameCount);
} // namespace reasampler::instrument::engine::loop
+6
View File
@@ -170,6 +170,12 @@ struct SampleData {
int rootNote = 60; int rootNote = 60;
SampleLoop loop; 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. // 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). // Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
std::int64_t startFrame = 0; std::int64_t startFrame = 0;
+14 -6
View File
@@ -66,6 +66,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
readPos_ = static_cast<double>(start); readPos_ = static_cast<double>(start);
startFrame_ = start; // the span-offset origin: readPos - startFrame 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 // 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. // 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) 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. // no per-frame shifter cost.
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window(); const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable(); const bool loopWrap = loop_.active;
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured(); const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// The prime may only carry playable source. The per-frame feed stops at feedBound // 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 // (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; std::int64_t q = start;
for (std::int64_t i = 0; i < primeCount; ++i) { for (std::int64_t i = 0; i < primeCount; ++i) {
if (loopWrap) { 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 // 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)] = 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; ++q;
} }
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount); (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
+58 -22
View File
@@ -16,6 +16,7 @@
#include "core/instrument/engine/filter/filter_params.h" #include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h" #include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_params.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/pitch_shift.h"
#include "core/instrument/engine/play_params.h" #include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/velocity_curve.h" #include "core/instrument/engine/velocity_curve.h"
@@ -26,6 +27,8 @@ using audio::AudioSample;
using instrument::engine::PitchShifter; using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint; 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 // 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
// reference-frequency needed. // reference-frequency needed.
@@ -151,14 +154,30 @@ public:
} }
private: private:
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the // Linear-interpolated read at a plain (non-wrapping) fractional source position. The
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared // crossfade tap sits one loop length behind the head, i.e. BEFORE the loop start, so it
// by the output anchor, the Preserve feed, and the start()-time ring prime. // never needs the wrap partner the main read uses.
bool sustainLoopUsable() const { static double lerpSource(const std::vector<AudioSample>& pcm, std::int64_t frameCount,
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false; double pos) {
const SampleLoop& loop = sample_->loop; const std::int64_t i0 = static_cast<std::int64_t>(pos);
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 && const std::int64_t i1 = i0 + 1;
loop.end <= static_cast<std::int64_t>(sample_->frames.size()); 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 // 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 bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm; const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). The
// valid, non-zero-length loop wraps the read head back into [start, end); a // span was folded once at note-on (loop_span.h); an invalid or absent loop leaves
// zero-length loop is "no loop". Under Preserve the loop is over the source read // loop_.active false and this whole path off. Under Preserve the loop is over the
// (loop the source, shift the output). // source read (loop the source, shift the output).
const SampleLoop& loop = sample_->loop; const ResolvedLoop& loop = loop_;
const bool loopUsable = sustainLoopUsable(); if (loop.active) {
if (loopUsable) { const double loopLen = static_cast<double>(loop.length);
const double loopLen = static_cast<double>(loop.end - loop.start);
while (readPos_ >= static_cast<double>(loop.end)) { while (readPos_ >= static_cast<double>(loop.end)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. 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 // 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 // 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. // frame the shifter's writer is frozen — it recycles the real tail it already holds.
if (loopUsable) { if (loop.active) {
const std::int64_t loopLen = loop.end - loop.start; while (feedPos_ >= loop.end) feedPos_ -= loop.length;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
} }
// feedPos_ runs one window ahead of readPos_; the last real source frame is // 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 // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
@@ -428,7 +445,11 @@ private:
const bool exhausted = feedPos_ >= feedBound; const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount); 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; const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift); shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL)); const double shiftedL = static_cast<double>(shiftL_.process(feedL));
@@ -447,7 +468,7 @@ private:
// not leak a previous note. // not leak a previous note.
if (exhausted) shiftR_.freezeTail(); if (exhausted) shiftR_.freezeTail();
const AudioSample feedR = const AudioSample feedR =
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f; feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift); shiftR_.setShiftRatio(shift);
outRlocal = outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())); 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 std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0); const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1; 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. i1 = loop.start; // seamless wrap for the interpolation partner.
} }
const bool i0ok = (i0 >= 0 && i0 < frameCount); const bool i0ok = (i0 >= 0 && i0 < frameCount);
@@ -486,6 +507,16 @@ private:
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac; (i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
outRlocal = srcR; 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; ratio_ = baseRatio_ * envFactor;
} }
@@ -569,6 +600,11 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished 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 // 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 // different envelope phases are filtered independently. filterCutoffNorm_ keeps the
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants // 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 // 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 — // params_payload, which grows on its own version axis. Every wire format is FROZEN —
// byte-identical across revisions. // byte-identical across revisions.
+12 -5
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine. // 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 // 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. // and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint> #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 // 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. // 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 // v10 is v9 PLUS the staged-curve tail, appended after the filter's velocity curve, all
// velocity curve, all 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve // 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, // 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 // 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 // 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. // 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 // 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 // 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 // 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. // 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 // 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 // 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 // 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. // 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; inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and // 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). // v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
inline constexpr std::uint32_t kParamsCurveVersion = 10; 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 // (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 // 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 // (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.decayCurve));
putLE(out, doubleToBits(f.env.releaseCurve)); putLE(out, doubleToBits(f.env.releaseCurve));
putAhd(out, f.trigEnv); 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 // 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); readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p); if (pv >= kParamsFilterVersion) readFilterTail(r, p);
if (pv >= kParamsCurveVersion) readCurveStageTail(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 — // A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had. // the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{}; if (!r.ok) return PayloadRead{};
+1 -1
View File
@@ -5,7 +5,7 @@
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay // responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
// serialize/deserializeComponentState; nothing outside the codec calls these. // 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. // one home. EVERY wire format is FROZEN.
#include <cstdint> #include <cstdint>
+2
View File
@@ -270,6 +270,7 @@ ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start). // The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
// The bank is never mutated. // The bank is never mutated.
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop; rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
rs.loopCrossfadeFrames = params.loopCrossfadeFrames;
rs.startFrame = params.startPoint ? *params.startPoint : 0; rs.startFrame = params.startPoint ? *params.startPoint : 0;
rs.play = params.play; // SECONDS; buildSampleData resolves to frames rs.play = params.play; // SECONDS; buildSampleData resolves to frames
return rs; return rs;
@@ -306,6 +307,7 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
data.sampleRate = decoded.sampleRate; data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote; data.rootNote = resolved.rootNote;
data.loop = resolved.loop; data.loop = resolved.loop;
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
data.startFrame = resolved.startFrame; data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack; data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve; data.velocityCurve = resolved.velocityCurve;
+7
View File
@@ -227,6 +227,12 @@ struct InstrumentParams {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0 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 // 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 // (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 // 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; double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat(); VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic 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 std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
PlaySeconds play; // stored SECONDS; resolved to frames at build PlaySeconds play; // stored SECONDS; resolved to frames at build
}; };
+12
View File
@@ -67,6 +67,18 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
return clampFrame(num / static_cast<std::int64_t>(w), frameCount); 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 markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y) { int count, int x, int y) {
if (count <= 0 || frames == nullptr) return -1; if (count <= 0 || frames == nullptr) return -1;
+9
View File
@@ -65,6 +65,15 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram
// area.x yields 0; right of area.right() yields frameCount. // area.x yields 0; right of area.right() yields frameCount.
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x); 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 // 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 // (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. // its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
+32 -9
View File
@@ -52,18 +52,31 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
} }
} }
const SetupMarkers m = pickedMarkers(frames); 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 std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y); const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
if (hit >= 0) { if (hit >= 0) {
beginMarkerDrag(static_cast<WaveMarker>(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; drag_ = DragKind::kWaveMarker;
waveMarker_ = static_cast<WaveMarker>(hit); waveMarker_ = which;
dragStartX_ = x; dragStartX_ = x;
dragStartMarkers_ = m; dragStartMarkers_ = m;
dragSampleFrames_ = frames; dragSampleFrames_ = frames;
dragStartParams_ = params_; dragStartParams_ = params_;
return true;
}
return false;
} }
void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
@@ -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). // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_); const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd}; dragStartMarkers_.loopEnd,
dragStartMarkers_.loopStart -
dragStartMarkers_.crossfade};
std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx); 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 // 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<AudioSample>& pcm = monoPcmFor(selectedId_); const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) { if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()), newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame); newFrame);
} }
@@ -116,12 +132,19 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
} else if (waveMarker_ == WaveMarker::kLoopStart) { } else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd); m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true; m.hasLoop = true;
} else { // kLoopEnd } else if (waveMarker_ == WaveMarker::kLoopEnd) {
m.loopEnd = (std::max)(newFrame, m.loopStart); m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true; 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 < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1; 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); applyMarkers(m);
invalidate(); // live feedback; the commit lands on release invalidate(); // live feedback; the commit lands on release
@@ -98,6 +98,17 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
static_cast<float>(kLoopSpanFillAlpha), 0); static_cast<float>(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<float>(kLoopSpanFillAlpha) * 0.5f, 0);
}
}
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) { 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, LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height,
toLice(roleColor(markerRoles[i])), alpha, 0); 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); paintEnvelopeOverlay(bmp, overlay, frames);
} }
+21 -6
View File
@@ -17,6 +17,7 @@
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key) #include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
#include "core/util/file_bytes.h" // shared whole-file loader #include "core/util/file_bytes.h" // shared whole-file loader
#include "ext_keys.h" #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 "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
#include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -31,6 +32,8 @@ using capture::WavLayout;
using capture::extractFloatFrames; using capture::extractFloatFrames;
using capture::parseWavLayout; using capture::parseWavLayout;
using capture::resolveBankFile; using capture::resolveBankFile;
using instrument::engine::loop::LoopBounds;
using instrument::engine::loop::defaultLoopBounds;
using instrument::ui::nameMatchesQuery; using instrument::ui::nameMatchesQuery;
using ui::ThumbnailKey; using ui::ThumbnailKey;
using ui::thumbnailKeyString; 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 // 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 // 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 // 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; selectedId_ = id;
params_.rootOverride.reset(); params_.rootOverride.reset();
params_.loopOverride.reset(); params_.loopOverride.reset();
params_.loopCrossfadeFrames = 0;
params_.startPoint.reset(); params_.startPoint.reset();
commitAndReload(); commitAndReload();
} }
@@ -196,10 +201,17 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
m.loopEnd = params_.loopOverride->end; m.loopEnd = params_.loopOverride->end;
} }
if (params_.startPoint) m.start = *params_.startPoint; if (params_.startPoint) m.start = *params_.startPoint;
// Default an unset loop's end to the sample length so the loop markers have somewhere sane m.crossfade = params_.loopCrossfadeFrames;
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; // A collapsed or inverted span is the OFF state (the engine refuses it either way), so
// the markers are still drawn (drag one to CREATE a loop). // park the handles on the shared default rather than leaving them stacked on each other
if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; // 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; 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 // Write the edited markers into the parameter set as the loop/start override. The bank
// intrinsic is never written (read-only bank consumer). // intrinsic is never written (read-only bank consumer).
SampleLoop loop; 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.start = m.loopStart;
loop.end = m.loopEnd; loop.end = m.loopEnd;
params_.loopOverride = loop; params_.loopOverride = loop;
params_.loopCrossfadeFrames = m.crossfade;
params_.startPoint = m.start; params_.startPoint = m.start;
} }
+14 -4
View File
@@ -93,8 +93,11 @@ private:
using ParamControl = instrument::ui::DeckParam; using ParamControl = instrument::ui::DeckParam;
// The waveform markers on the waveform band: start-point + the sustain loop's two ends, // The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// in draw + hit order. // in draw + hit order, then the crossfade handle. The crossfade is NOT part of the
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 }; // 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` // 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 // 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; std::string samplePathFor(const std::string& sampleId) const;
// The effective loop + start markers for the loaded capture: the parameter set's // 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 -> // override when one is set, else the bank's loop intrinsic / frame 0. With no loop set,
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty. // 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 { struct SetupMarkers {
std::int64_t start = 0; std::int64_t start = 0;
std::int64_t loopStart = 0; std::int64_t loopStart = 0;
std::int64_t loopEnd = 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) bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance)
}; };
SetupMarkers pickedMarkers(std::int64_t frames) const; SetupMarkers pickedMarkers(std::int64_t frames) const;
@@ -305,6 +311,10 @@ private:
// callers decide live-drag vs final commit. // callers decide live-drag vs final commit.
void applyMarkers(const SetupMarkers& m); 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 // 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. // pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
+127 -4
View File
@@ -418,6 +418,7 @@ static void testGoldenFullBlobFixture() {
loopA.start = 1000; loopA.start = 1000;
loopA.end = 5000; loopA.end = 5000;
in.params.loopOverride = loopA; in.params.loopOverride = loopA;
in.params.loopCrossfadeFrames = 256;
in.params.startPoint = 250; in.params.startPoint = 250;
in.params.keyTrack = 0.5; in.params.keyTrack = 0.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( 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,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, 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, 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, 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, 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, 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 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 att curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec 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 // clang-format on
CHECK(bytes.size() == sizeof(kGolden)); CHECK(bytes.size() == sizeof(kGolden));
@@ -551,13 +554,16 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono CHECK(bytes[4] == 0); // ChannelMode::Mono
} }
CHECK(kComponentStateVersion == 11); CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 10); CHECK(kParamsPayloadVersion == 11);
CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u); CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes // The filter, staged-curve and loop tails rode PAYLOAD bumps, not envelope ones — the two
// stay independent, so a future envelope field cannot collide with either on one number. // axes stay independent, so a future envelope field cannot collide with any of them on one
// number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion);
CHECK(kParamsLoopVersion > kParamsCurveVersion);
CHECK(kParamsPayloadVersion == kParamsLoopVersion);
} }
// --- The filter tail (payload v9) -------------------------------------------- // --- The filter tail (payload v9) --------------------------------------------
@@ -701,6 +707,120 @@ static void testNonFiniteAhdSecondsLiftToZero() {
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0); 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<std::uint8_t> payloadDowngradedTo(const ComponentState& state,
std::uint32_t pv, std::size_t cutBytes) {
std::vector<std::uint8_t> bytes = serializeComponentState(state);
bool stamped = false;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
(static_cast<std::uint32_t>(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<std::uint8_t>(kParamsPayloadVersion));
bytes[i + 4] = static_cast<std::uint8_t>(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 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 // the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals. // against the semantic constants, not literals.
@@ -1270,6 +1390,9 @@ int main() {
testGoldenFullBlobFixture(); testGoldenFullBlobFixture();
testDefaultStateRoundTripsToDefaults(); testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen(); testEnvelopePrefixBytesFrozen();
testLoopSpanAndCrossfadeRoundTrip();
testNegativeCrossfadeOnTheWireLiftsToZero();
testPriorPayloadVersionsLiftToAHardSeam();
testWriterEmitsCurrentPayloadVersion(); testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless(); testSingleZoneMigrationIsLossless();
testMigratedFadeContourTracksTheRetiredEqualPowerShape(); testMigratedFadeContourTracksTheRetiredEqualPowerShape();
+167
View File
@@ -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 <cstdio>
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<double>(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;
}
+253
View File
@@ -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<float>(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<AudioSample> 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<std::int64_t>(i)
: 40 + ((static_cast<std::int64_t>(i) - 60) % 20);
CHECK(approx(sourceFrameOf(out[i]), static_cast<double>(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<AudioSample> 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<AudioSample> 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<AudioSample> 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<double>(n - 52) / 8.0;
const double want = static_cast<double>(n) * (1.0 - w) + static_cast<double>(n - 20) * w;
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]), want, 1e-3));
}
for (int n = 53; n < 60; ++n) {
CHECK(out[static_cast<std::size_t>(n)] < out[static_cast<std::size_t>(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<AudioSample> out;
eng.render(out, 70);
for (int n = 40; n < 60; ++n) {
const double got = sourceFrameOf(out[static_cast<std::size_t>(n)]);
if (!approx(got, static_cast<double>(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<AudioSample> out;
eng.render(out, 60);
CHECK(eng.activeVoiceCount() == 1);
for (int n = 0; n < 20; ++n) {
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]),
static_cast<double>(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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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. // velocity -> volume.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2562,6 +2807,14 @@ int main() {
testStartFrameOutOfRangeClampsToZero(); testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop(); testStartFrameWithLoop();
testStartAfterLoopEndWrapsIntoLoop(); testStartAfterLoopEndWrapsIntoLoop();
testLoopReadWrapsSampleExactOverManyCycles();
testZeroCrossfadeLeavesTheSeamHard();
testCrossfadeBlendsMonotonelyAcrossTheRegion();
testSeamStepShrinksInProportionToTheFadeLength();
testCrossfadeLengthFollowsItsParameter();
testCrossfadeIsSuppressedForALoopAtFrameZero();
testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice();
testPreserveLoopsTheSourceSpanAtEveryTransposition();
testVelocityDefaultCurveIsFlatUnity(); testVelocityDefaultCurveIsFlatUnity();
testVelocityLinearCurveReproducesRamp(); testVelocityLinearCurveReproducesRamp();
testVelocityShapedCurveDrivesGain(); testVelocityShapedCurveDrivesGain();
+57
View File
@@ -5,6 +5,7 @@
// //
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width); // Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection); // 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 // 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-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked // 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); 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 ------------------------------------------------- // --- Per-lane envelope content -------------------------------------------------
static void testAsymmetricStereoLanesCarryDifferentContent() { static void testAsymmetricStereoLanesCarryDifferentContent() {
@@ -379,6 +431,11 @@ int main() {
testMarkerGrabReachesTheLowerStereoLane(); testMarkerGrabReachesTheLowerStereoLane();
testMarkerGrabInMonoSpansTheBand(); testMarkerGrabInMonoSpansTheBand();
testMarkerHandleIsATopStripCentredOnTheMarker();
testCoincidentMarkersStayIndependentlyGrabbable();
testMarkerHandleClipsIntoTheArea();
testMarkerHandleOnDegenerateAreas();
testAsymmetricStereoLanesCarryDifferentContent(); testAsymmetricStereoLanesCarryDifferentContent();
testLaneEnvelopeRejectsOutOfRangeLane(); testLaneEnvelopeRejectsOutOfRangeLane();