From 1e1d6bddbbc7d7eb575e6c4f8d3b11c226098fc6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:50:31 -0400 Subject: [PATCH 1/2] S15/S16: Gate(AHDSR)/Trigger play modes + Varispeed/Preserve pitch engines + AD pitch envelope Per-zone play params on SampleData; hand-rolled pure pitch_shift OLA for Preserve (WDL drags windows.h); zone-payload v3 tail; RT-safe pre-warmed shifters + Preserve voice cap. --- CLAUDE.md | 1 + CMakeLists.txt | 18 +- src/vst/pitch_shift.cpp | 126 +++++++++++ src/vst/pitch_shift.h | 88 ++++++++ src/vst/reasampler_processor.cpp | 16 +- src/vst/reasampler_processor.h | 7 +- src/vst/sample_map.cpp | 53 ++++- src/vst/sample_map.h | 50 ++++- src/vst/sampler_core.cpp | 327 +++++++++++++++++++++++---- src/vst/sampler_core.h | 256 ++++++++++++++++++++-- tests/test_pitch_shift.cpp | 187 ++++++++++++++++ tests/test_sample_map.cpp | 111 ++++++++++ tests/test_sampler_core.cpp | 365 +++++++++++++++++++++++++++++++ 13 files changed, 1528 insertions(+), 77 deletions(-) create mode 100644 src/vst/pitch_shift.cpp create mode 100644 src/vst/pitch_shift.h create mode 100644 tests/test_pitch_shift.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 12c14a6..a4c8887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ Key targets (see CMakeLists.txt for the full list): | `tooltip_tests` | executable | Pure unit tests for `tooltip` — no REAPER, no DAW. | | `card_drag_tests` | executable | Pure unit tests for `card_drag` — no REAPER, no DAW. | | `card_meta_tests` | executable | Pure unit tests for `card_meta` — no REAPER, no DAW. | +| `pitch_shift_tests` | executable | Pure unit tests for `pitch_shift` (S16 Preserve engine) — no REAPER, no DAW. | | `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). | ### Beta channel build (Phase V, V4) diff --git a/CMakeLists.txt b/CMakeLists.txt index a0328a4..dc9782d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -471,9 +471,18 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # the one house precedent wav_trim also relies on). The VST3 shell (src/vst/ # reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. # --------------------------------------------------------------------------- +# pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core). +# NO VST3/REAPER/SWELL/vendor: a hand-rolled OLA shifter chosen over WDL_SimplePitchShifter +# because that header drags (via wdltypes.h) into any TU that includes it, which +# cannot enter the pure sampler_core. Links only peaks (the AudioSample alias). sampler_core +# depends on it (Voice owns two PitchShifters). +add_library(pitch_shift STATIC src/vst/pitch_shift.cpp) +target_include_directories(pitch_shift PUBLIC src src/vst) +target_link_libraries(pitch_shift PUBLIC peaks) + add_library(sampler_core STATIC src/vst/sampler_core.cpp) target_include_directories(sampler_core PUBLIC src src/vst) -target_link_libraries(sampler_core PUBLIC peaks) +target_link_libraries(sampler_core PUBLIC peaks pitch_shift) # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). @@ -620,6 +629,13 @@ add_test(NAME assignment_request_tests COMMAND assignment_request_tests) # sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the # VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data # boundary (a VST3/REAPER type in the core would fail to compile/link here). +# pitch_shift (S16): the pure Preserve-engine OLA shifter. Links ONLY pitch_shift (+ peaks) — +# NEITHER SDK — the same plain-data-boundary proof, and specifically the compile-time proof it +# does NOT drag in the WDL chain the built-in WDL shifter would. +add_executable(pitch_shift_tests tests/test_pitch_shift.cpp) +target_link_libraries(pitch_shift_tests PRIVATE pitch_shift) +add_test(NAME pitch_shift_tests COMMAND pitch_shift_tests) + add_executable(sampler_core_tests tests/test_sampler_core.cpp) target_link_libraries(sampler_core_tests PRIVATE sampler_core) add_test(NAME sampler_core_tests COMMAND sampler_core_tests) diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp new file mode 100644 index 0000000..d957543 --- /dev/null +++ b/src/vst/pitch_shift.cpp @@ -0,0 +1,126 @@ +// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2 +// route-(b) rationale (WDL drags , so the Preserve DSP is house-native here). +// NO VST3 / REAPER / SWELL / vendor includes; standard library only. +// +// Algorithm: a single delay ring of `window_` frames. The write head advances one frame per +// input sample (source rate → duration preserved). TWO read taps chase the write head, offset +// by half a window; each advances by the shift `ratio_` per frame. A tap that would cross the +// write head wraps by a full window (so it stays a bounded delay behind the writer). The two +// taps are crossfaded by an equal-power window keyed to each tap's distance from the write +// head, so the wrap discontinuity of one tap is masked by the other mid-window — the classic +// two-grain time-domain pitch shifter, no FFT. + +#include "pitch_shift.h" + +#include +#include + +namespace reasampler { + +namespace { + +// A Hann OLA window over a grain phase in [0,1): 0.5(1 - cos(2*pi*phase)). Zero at the grain +// ends (where a tap wraps — the discontinuity), unity mid-grain. Two grains offset by half a +// window PARTITION UNITY (w(p) + w(p+0.5) == 1 for all p), so the two crossfaded taps sum to a +// gain of exactly 1 everywhere — no amplitude ripple across the window, and each tap's wrap +// seam is masked because its window is 0 exactly there. +double hannWeight(double phase) { + while (phase < 0.0) phase += 1.0; + while (phase >= 1.0) phase -= 1.0; + return 0.5 * (1.0 - std::cos(2.0 * 3.14159265358979323846 * phase)); +} + +} // namespace + +void PitchShifter::configure(std::int64_t windowFrames) { + window_ = windowFrames; + if (window_ <= 1) { + // Pass-through: no ring, process() returns input unchanged. + ring_.clear(); + writePos_ = 0; + readPos_ = 0.0; + ratio_ = 1.0; + return; + } + ring_.assign(static_cast(window_), 0.0f); + reset(); +} + +void PitchShifter::reset() { + if (window_ > 1) { + // Zero the ring and seed the read head a half-window behind the writer so the two taps + // (readPos_ and readPos_ + window/2) straddle the writer from the first frame. + std::fill(ring_.begin(), ring_.end(), 0.0f); + writePos_ = 0; + readPos_ = static_cast(window_) / 2.0; + } else { + writePos_ = 0; + readPos_ = 0.0; + } + ratio_ = 1.0; +} + +void PitchShifter::warm() { + if (window_ <= 1) return; // pass-through needs no warm-up + // Push one full window of silence so the taps reach steady state before real audio. + for (std::int64_t i = 0; i < window_; ++i) process(0.0f); +} + +void PitchShifter::setShiftRatio(double ratio) { + if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run taps backward/stall) +} + +AudioSample PitchShifter::process(AudioSample in) { + if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) + + // 1. Write the incoming sample at the write head (source rate). + ring_[static_cast(writePos_)] = in; + + const double w = static_cast(window_); + const double half = w / 2.0; + + // 2. Read the two taps, each a bounded delay behind the writer. tap0 is `readPos_`; tap1 is + // a half-window ahead of it (mod window). Distance-from-writer drives the crossfade so a + // tap near the writer (about to wrap) is faded out while its partner (mid-window) is up. + auto readTap = [&](double pos) -> double { + // Fractional linear interpolation with ring wrap. + double p = pos; + while (p < 0.0) p += w; + while (p >= w) p -= w; + const std::int64_t i0 = static_cast(p); + const double frac = p - static_cast(i0); + std::int64_t i1 = i0 + 1; + if (i1 >= window_) i1 = 0; + const double s0 = static_cast(ring_[static_cast(i0)]); + const double s1 = static_cast(ring_[static_cast(i1)]); + return s0 + (s1 - s0) * frac; + }; + + const double tap0 = readTap(readPos_); + const double tap1 = readTap(readPos_ + half); + + // Distance of tap0 behind the write head, in [0, window). Its crossfade phase is that + // distance over the window; tap1 (half a window offset) gets the complementary phase. + double dist0 = static_cast(writePos_) - readPos_; + while (dist0 < 0.0) dist0 += w; + while (dist0 >= w) dist0 -= w; + const double phase0 = dist0 / w; + + // Hann windows offset by half a grain partition unity, so the two taps sum to gain 1 with + // each tap's wrap seam masked by its window zero. phase0 drives tap0; tap1 (half-window + // offset) is at phase0 + 0.5. + const double g0 = hannWeight(phase0); + const double g1 = hannWeight(phase0 + 0.5); + const double out = tap0 * g0 + tap1 * g1; + + // 3. Advance heads: write head one frame (source rate), read head by the shift ratio. + ++writePos_; + if (writePos_ >= window_) writePos_ = 0; + readPos_ += ratio_; + while (readPos_ >= w) readPos_ -= w; + while (readPos_ < 0.0) readPos_ += w; + + return static_cast(out); +} + +} // namespace reasampler diff --git a/src/vst/pitch_shift.h b/src/vst/pitch_shift.h new file mode 100644 index 0000000..d96ed9c --- /dev/null +++ b/src/vst/pitch_shift.h @@ -0,0 +1,88 @@ +#pragma once +// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" +// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps +// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1 +// (duration held); only the PITCH changes — an octave up plays the same wall-clock length +// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path. +// +// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was +// route (a) `WDL_SimplePitchShifter`. But its include chain +// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> +// #include ` unconditionally, which CANNOT enter the pure sampler_core module +// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither +// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native +// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same +// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at +// the SHELL, never in the pure core. +// +// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only. +// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core / +// wav_trim do the same). +// +// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio +// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state +// latency is reached before the first real sample (no cold-start click). `process()` does +// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain +// value fields, so a voice owning one by value costs a fixed ring buffer per channel. + +#include +#include +#include + +#include "peaks.h" // AudioSample (float) + +namespace reasampler { + +// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo +// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count +// agnostic, matching the S7 "one read head, per-channel value" idiom of the core. +// +// The default-constructed shifter is INERT: with no configure() it passes input through +// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is +// byte-identical to the pre-S16 engine. +class PitchShifter { +public: + // Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two + // read taps a half-window apart. `windowFrames` <= 1 degrades to pass-through (no ring), + // so a degenerate configure never divides by zero or wraps a zero span. Called OFF the + // audio thread (allocates). Resets all running state. A larger window = smoother on large + // transpositions but more latency; the shell picks it from the Preserve quality setting. + void configure(std::int64_t windowFrames); + + // Pre-fill the ring with silence (one full window of zero writes) so the read taps reach + // steady state before the first real sample. Removes the cold-start seam (the S16 "onset + // click absent" requirement) — call once at voice allocation after configure(). No-op when + // unconfigured (pass-through needs no warm-up). + void warm(); + + // The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. + // 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap + // advance simply uses the current value. Values <= 0 are ignored (kept at the last valid + // ratio) so a bad input never runs the taps backward or stalls them. + void setShiftRatio(double ratio); + + // Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). + // RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured + // (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write + // head, reads the two half-window-offset taps advancing at the shift ratio, crossfades + // them by the write-head-relative distance (equal-power), and advances both heads by one. + AudioSample process(AudioSample in); + + // Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded) + // WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window. + void reset(); + + // True once configure() sized a real ring (window > 1). A pass-through shifter is false. + bool configured() const { return window_ > 1; } + + std::int64_t window() const { return window_; } + +private: + std::vector ring_; // delay line, length `window_` (channel-local) + std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through + std::int64_t writePos_ = 0; // integer write head into the ring (source rate) + double readPos_ = 0.0; // fractional read head (advances at shift ratio) + double ratio_ = 1.0; // current shift ratio (>0) +}; + +} // namespace reasampler diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 7d65188..e6c49d2 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -41,6 +41,13 @@ constexpr double kSustainLevel = 1.0; constexpr double kReleaseSeconds = 0.060; constexpr std::size_t kMaxVoices = 16; +// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is +// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of +// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather +// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice +// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling. +constexpr std::size_t kPreserveVoiceCap = 8; + AdsrParams tier0Adsr(double sampleRate) { const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; AdsrParams p; @@ -363,8 +370,15 @@ std::string ReaSamplerProcessor::reloadFromBank() { } if (haveKeymap) { + // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). + // Every voice's shifter is pre-sized to this off-thread here, so process()-time + // note-on never allocates. Floored at 2 so a valid window is always a real ring. + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); + std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap, + preserveWindow); } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index a16e283..32b9492 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -51,8 +51,11 @@ struct LoadedInstrument { std::uint64_t installedAt = 0; // reload generation at which this was installed LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, - std::uint64_t gen) - : keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {} + std::uint64_t gen, std::size_t preserveVoiceCap = 0, + std::int64_t preserveWindowFrames = 0) + : keymap(std::move(km)), + engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames), + installedAt(gen) {} LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument& operator=(const LoadedInstrument&) = delete; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 64c95b8..7b2deff 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -135,7 +135,7 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, - std::vector framesR) { + std::vector framesR, const ZonePlayParams& play) { SampleData data; data.frames = std::move(frames); // A second channel only counts when it length-matches channel 0 (else the sample stays @@ -146,6 +146,7 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.rootNote = rootNote; data.loop = loop; + data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) return Keymap::singleSampleChromatic(std::move(data)); } @@ -186,6 +187,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // never mutated — this only shapes what the core plays for THIS instance (D-B). rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.startFrame = z.startPoint ? *z.startPoint : 0; + // S15/S16 per-zone play params carry through unchanged (they are instrument state, not + // resolved against the bank) so the keymap build can stamp them onto the SampleData. + rz.play = z.play; out.zones.push_back(std::move(rz)); } return out; @@ -210,6 +214,7 @@ Keymap buildZonedKeymap(const std::vector& zones, data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) + data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -241,6 +246,19 @@ void putU64le(std::vector& out, std::uint64_t v) { std::uint64_t asU64(std::int64_t v) { return static_cast(v); } +// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++). +// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields. +std::uint64_t doubleToBits(double d) { + std::uint64_t bits; + std::memcpy(&bits, &d, sizeof(bits)); + return bits; +} +double bitsToDouble(std::uint64_t bits) { + double d; + std::memcpy(&d, &bits, sizeof(d)); + return d; +} + // A bounded little-endian reader over a byte blob. Every read is length-checked; once a // read runs past the end the reader latches `ok=false` and yields zeros, so a truncated // blob degrades to a partial/empty parse rather than reading out of bounds. @@ -324,6 +342,20 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) } out.push_back(z.startPoint ? 1 : 0); if (z.startPoint) putU64le(out, asU64(*z.startPoint)); + + // S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone + // has a play mode + engine — no flag gate). Order matches the header's v3 record spec. + const ZonePlayParams& pp = z.play; + out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); + putU64le(out, asU64(pp.adsr.holdFrames)); + putU64le(out, doubleToBits(pp.trigger.lengthFraction)); + putU64le(out, asU64(pp.trigger.fadeInFrames)); + putU64le(out, asU64(pp.trigger.fadeOutFrames)); + out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); + out.push_back(pp.pitchEnv.enabled ? 1 : 0); + putU64le(out, asU64(pp.pitchEnv.attackFrames)); + putU64le(out, asU64(pp.pitchEnv.decayFrames)); + putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); } } @@ -333,14 +365,18 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // keeps the zones that parsed cleanly and drops the rest. void readZonesPayload(ByteReader& r, PerformanceMap& map) { - bool extended = false; + bool extended = false; // v2+: the S11 loop/start tail is present + bool hasPlay = false; // v3+: the S15/S16 play-params tail is present if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker const std::uint32_t pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail + hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail } const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { + // z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play + // tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change. PerformanceZone z; const std::uint32_t idLen = r.u32(); z.sampleId = r.str(idLen); @@ -360,6 +396,19 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { const std::uint8_t hasStart = r.u8(); if (hasStart) z.startPoint = r.i64(); } + if (hasPlay) { + // S15/S16 play params, always present in a v3 record (read in the emit order). + z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; + z.play.adsr.holdFrames = r.i64(); + z.play.trigger.lengthFraction = bitsToDouble(r.u64()); + z.play.trigger.fadeInFrames = r.i64(); + z.play.trigger.fadeOutFrames = r.i64(); + z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; + z.play.pitchEnv.enabled = (r.u8() != 0); + z.play.pitchEnv.attackFrames = r.i64(); + z.play.pitchEnv.decayFrames = r.i64(); + z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + } if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index f527135..5b7bd92 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -114,9 +114,16 @@ std::vector extractChannel(const std::vector& interlea // which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // pair never half-plays. `sampleRate` is the WAV's rate. +// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to +// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under +// the same default engine as a zone would. The editor will surface per-capture overrides later +// (S15-F2 one-zone-map lean); until then this is the one place the single-capture default lives. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, - std::vector framesR = {}); + std::vector framesR = {}, + const ZonePlayParams& play = ZonePlayParams{ + PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + PitchEnvParams{}}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -147,6 +154,17 @@ struct PerformanceZone { std::optional rootOverride; // instrument-owned override; absent -> bank intrinsic std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 + + // S15/S16 per-zone play parameters (play mode + AHDSR hold + Trigger %-length/fades; pitch + // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the + // loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode, + // hold 0, no fades, and the PRESERVE pitch engine (S16-F1 — Daniel's directive; the one + // flippable default is sampler_core::kDefaultPitchEngine), pitch envelope off. An older + // zone-payload blob (no S15/S16 tail) lifts to exactly these defaults on read (see the + // PAYLOAD v3 versioning in the (de)serialize section), so a pre-S15 instrument opens with + // Gate + Preserve — the deliberate, spec-flagged behavior change. + ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + PitchEnvParams{}}; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -170,6 +188,8 @@ struct ResolvedZone { int rootNote = 60; // effective: override, else bank intrinsic, else 60 SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) + ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) }; // The result of resolving a performance map against the live bank blob. `zones` are the @@ -249,6 +269,20 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // The reader detects the marker to know the record shape — a v1 payload (no marker) reads // the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS, +// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play +// params — always present, NOT flag-gated, since every zone has a play mode + engine): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage (A/D/S/R timing stays +// instrument-wide; only hold is per-zone); +// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64); +// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double. +// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + +// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved +// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes @@ -261,12 +295,14 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, inline constexpr std::uint32_t kPerformanceStateVersion = 2; -// The zones-payload format version and its detection marker (S11). serializePerformance and -// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the -// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload -// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count -// (bounded by 128 MIDI zones in practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 2; +// The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance +// and serializeComponentState both emit the CURRENT payload version (v3 — marker + version + +// records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides +// round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload +// (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. +// The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in +// practice, always tiny) can never collide with. +inline constexpr std::uint32_t kZonesPayloadVersion = 3; // S15/S16: per-zone play params tail inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; // The performance map serialized to bytes for IBStream (getState). diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 5bdd075..88de67e 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -85,6 +85,32 @@ double AdsrEnvelope::tick() { const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.attackFrames) { + // S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight + // through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path. + stage_ = Stage::Hold; + framesInStage_ = 0; + level_ = 1.0; + } + return out; + } + + case Stage::Hold: { + // S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the + // stage on this same tick (no frame consumed at 1.0 beyond what Attack already + // emitted), so hold=0 is byte-identical to the pre-S15 envelope. + if (params_.holdFrames <= 0) { + stage_ = Stage::Decay; + framesInStage_ = 0; + // Fall through to Decay this frame so no extra unity sample is emitted for a + // zero-length hold (preserving the exact pre-S15 sample-for-sample shape). + level_ = 1.0; + // Re-dispatch by recursion-free goto-equivalent: evaluate Decay immediately. + return tick(); + } + level_ = 1.0; + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.holdFrames) { stage_ = Stage::Decay; framesInStage_ = 0; level_ = 1.0; @@ -136,46 +162,194 @@ double AdsrEnvelope::tick() { return 0.0; // unreachable; silences a warning. } +// --------------------------------------------------------------------------- +// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function. +// --------------------------------------------------------------------------- + +void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, + std::int64_t fadeOutFrames, FadeCurve curve) { + playLength_ = playLengthFrames > 0 ? playLengthFrames : 0; + curve_ = curve; + finished_ = (playLength_ <= 0); + + // Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A + // negative fade is treated as 0. When both fades together exceed the play length, shrink + // the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp), + // then the fade-in — never letting either go negative or the sum exceed the span. + std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0; + std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0; + if (fi > playLength_) fi = playLength_; + if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_ + fadeIn_ = fi; + fadeOut_ = fo; +} + +double TriggerEnvelope::amplitudeAt(double sourceOffset) { + if (finished_ || sourceOffset < 0.0 || + sourceOffset >= static_cast(playLength_)) { + // At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd. + if (sourceOffset >= static_cast(playLength_)) finished_ = true; + return 0.0; + } + + // Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_). + // Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length). + // The offset is fractional (the read head is fractional under repitch), so the ramps are + // smooth rather than stepped. + double amp = 1.0; + const double foStart = static_cast(playLength_ - fadeOut_); + if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { + const double phase = sourceOffset / static_cast(fadeIn_); // 0..1 + amp = (curve_ == FadeCurve::EqualPower) + ? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power + : phase; + } else if (fadeOut_ > 0 && sourceOffset >= foStart) { + const double phase = (sourceOffset - foStart) / static_cast(fadeOut_); // 0..1 + amp = (curve_ == FadeCurve::EqualPower) + ? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power + : (1.0 - phase); + } + return amp; +} + +// --------------------------------------------------------------------------- +// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled. +// --------------------------------------------------------------------------- + +double PitchEnvelope::tick() { + if (!params_.enabled) return 0.0; + + const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0; + const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0; + const double peak = params_.peakSemitones; + + double offset; + if (pos_ < a) { + // Attack: 0 -> peak over attackFrames (rise into the peak). + offset = peak * (static_cast(pos_) / static_cast(a)); + } else if (pos_ < a + d) { + // Decay: peak -> 0 over decayFrames (settle to base pitch). + const double t = static_cast(pos_ - a) / static_cast(d); + offset = peak * (1.0 - t); + } else { + offset = 0.0; // past attack+decay: at base pitch forever. + } + ++pos_; + return offset; +} + // --------------------------------------------------------------------------- // Voice // --------------------------------------------------------------------------- +void Voice::presizePreserveShifters(std::int64_t windowFrames) { + // OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs + // no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. + shiftL_.configure(windowFrames); + shiftR_.configure(windowFrames); +} + void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& adsr) { + const AdsrParams& gateAdsr) { active_ = true; releasing_ = false; + amplitudeDone_ = false; note_ = note; // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. int v = velocity; if (v < 0) v = 0; if (v > 127) v = 127; velocityGain_ = static_cast(v) / 127.0; - ratio_ = pitchRatio(note, rootNote); - // Initial read position honors the sample's start-point offset (S11). Clamp into - // [0, frames): a start at or past the end degrades to 0 (play from the top) rather - // than starting a voice already off the end. A negative start (shouldn't occur — - // the map clamps) is likewise pinned to 0. + baseRatio_ = pitchRatio(note, rootNote); + sample_ = &sample; + + const ZonePlayParams& p = sample.play; + playMode_ = p.playMode; + pitchEngine_ = p.pitchEngine; + + // Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp + // into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than + // starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0. const std::int64_t frameCount = static_cast(sample.frames.size()); std::int64_t start = sample.startFrame; if (start < 0 || start >= frameCount) start = 0; readPos_ = static_cast(start); - sample_ = &sample; - env_.configure(adsr); - env_.noteOn(); + startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) + + // --- Amplitude envelope: Gate = AHDSR (instrument A/D/S/R + per-zone HOLD); Trigger = the + // time-boxed fade-in/out over the % play length. --- + if (playMode_ == PlayMode::Gate) { + AdsrParams a = gateAdsr; + a.holdFrames = p.adsr.holdFrames; // per-zone hold folds into the instrument-wide AHDSR + env_.configure(a); + env_.noteOn(); + playEnd_ = 0; // unused in Gate + } else { + // Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)). + double frac = p.trigger.lengthFraction; + if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately) + if (frac > 1.0) frac = 1.0; + const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount) + std::int64_t playLen = static_cast( + static_cast(span) * frac + 0.5); // round + if (playLen < 0) playLen = 0; + if (playLen > span) playLen = span; + playEnd_ = start + playLen; + trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames, + kDefaultFadeCurve); + } + + // --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). --- + pitchEnv_.configure(p.pitchEnv); + pitchEnv_.noteOn(); + + // --- Preserve engine (S16): reset + pre-warm the ALREADY-SIZED per-channel shifters. The + // rings were allocated off-thread by presizePreserveShifters (the engine calls it at + // construction), so this RT-safe path only zeroes state (reset) and runs a silence pass + // (warm) to settle the OLA taps before the first output frame — NO allocation here. + // Varispeed voices never touch the shifters (advanceFrame checks configured()), so a + // Varispeed instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. --- + if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { + shiftL_.reset(); + shiftL_.warm(); + if (sample.channelCount() == 2 && shiftR_.configured()) { + shiftR_.reset(); + shiftR_.warm(); + } + } + ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine. } void Voice::release() { if (!active_) return; + // TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. + if (playMode_ == PlayMode::Trigger) return; releasing_ = true; env_.noteOff(); } +double Voice::tickAmplitude() { + double amp; + if (playMode_ == PlayMode::Gate) { + // AHDSR is wall-clock (one tick per output frame), independent of the read rate. + amp = env_.tick(); + if (env_.finished()) amplitudeDone_ = true; + } else { + // Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades + // land on the same source frames under either engine's read rate. The voice ALSO frees on + // readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders. + amp = trigEnv_.amplitudeAt(readPos_ - static_cast(startFrame_)); + if (trigEnv_.finished()) amplitudeDone_ = true; + } + return amp; +} + AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, // bracketing indices, interpolation partner) is computed ONCE and applied identically to - // every channel — only the PCM value read differs. The envelope ticks ONCE per frame and - // scales all channels equally (a voice is one envelope). The head advances by exactly one - // ratio step per call, so mono and stereo consume the sample at the same rate. + // every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE + // per frame and scale all channels equally (a voice is one envelope). The head advances by + // exactly one source-frame step per call, so mono and stereo consume the sample at one rate. if (!active_ || sample_ == nullptr) { if (stereo) outR = 0.0f; return 0.0f; @@ -188,30 +362,32 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { const bool haveR = stereo && sample_->channelCount() == 2; const std::vector& pcmR = haveR ? sample_->framesR : pcm; - // Loop-aware sustain: if a valid, non-zero-length loop exists and the read head - // has advanced past the loop end, wrap it back into [start, end). A zero-length - // loop (start == end) is treated as "no loop" — the note is allowed to run off - // the sample end and go silent, rather than spinning on a zero span. + // Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a + // valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap + // it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the + // loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract). const SampleLoop& loop = sample_->loop; - const bool loopUsable = loop.hasLoop && loop.end > loop.start && - loop.start >= 0 && loop.end <= frameCount; + const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop && + loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount; if (loopUsable) { - const std::int64_t loopStart = loop.start; - const std::int64_t loopEnd = loop.end; - const double loopLen = static_cast(loopEnd - loopStart); - while (readPos_ >= static_cast(loopEnd)) { + const double loopLen = static_cast(loop.end - loop.start); + while (readPos_ >= static_cast(loop.end)) { readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. } } - // Ran off the end with no usable loop -> voice is done. - if (readPos_ >= static_cast(frameCount)) { + // TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The + // trigger envelope also finishes at the same frame count; either latches the voice idle. + const bool triggerRanOff = + playMode_ == PlayMode::Trigger && readPos_ >= static_cast(playEnd_); + // Ran off the sample end with no usable loop -> voice is done. + if (triggerRanOff || readPos_ >= static_cast(frameCount)) { active_ = false; if (stereo) outR = 0.0f; return 0.0f; } - // Linear interpolation between the two bracketing frames. For the loop case, the + // Linear interpolation between the two bracketing SOURCE frames. For the loop case, the // second point wraps to loopStart so the seam is continuous. const std::int64_t i0 = static_cast(readPos_); const double frac = readPos_ - static_cast(i0); @@ -219,29 +395,71 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { if (loopUsable && i1 >= loop.end) { i1 = loop.start; // seamless wrap for the interpolation partner. } - // i0 is always in [0, frameCount) after the early-out above; the guard is purely - // defensive. i1 (the interpolation partner) can exceed frameCount when no loop - // wraps it — only that partner actually needs the clamp. framesR is length-matched - // to frames (channelCount() enforces it), so the same indices are valid in both. const bool i0ok = (i0 >= 0 && i0 < frameCount); const bool i1ok = (i1 >= 0 && i1 < frameCount); - const double amp = env_.tick(); + // Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine. + const double amp = tickAmplitude(); const double gain = amp * velocityGain_; + const double pitchEnvSemis = pitchEnv_.tick(); - const double l0 = i0ok ? static_cast(pcm[i0]) : 0.0; - const double l1 = i1ok ? static_cast(pcm[i1]) : 0.0; - const double outL = (l0 + (l1 - l0) * frac) * gain; - + // Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read; + // Varispeed applies pitch by the read RATE, Preserve applies it by the shifter. + const double srcL = (i0ok ? static_cast(pcm[i0]) : 0.0) + + ((i1ok ? static_cast(pcm[i1]) : 0.0) - + (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; + double srcR = 0.0; if (stereo) { - const double r0 = i0ok ? static_cast(pcmR[i0]) : 0.0; - const double r1 = i1ok ? static_cast(pcmR[i1]) : 0.0; - outR = static_cast((r0 + (r1 - r0) * frac) * gain); + srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + + ((i1ok ? static_cast(pcmR[i1]) : 0.0) - + (i0ok ? static_cast(pcmR[i0]) : 0.0)) * frac; } + // The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0) + // this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read + // (no per-frame transcendental), byte-identical to pre-S16. + const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); + + double outL, outRlocal = 0.0; + if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { + // PRESERVE: read the source at unity rate (duration held) and TRANSPOSE the output by + // 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to the shift amount, not the + // read rate — pitch bends, duration unchanged (S16 contract). + const double shift = baseRatio_ * envFactor; + shiftL_.setShiftRatio(shift); + const double shiftedL = static_cast(shiftL_.process(static_cast(srcL))); + outL = shiftedL * gain; + if (stereo) { + if (shiftR_.configured()) { + // Genuine stereo: an independent shifter transposes channel 1. Each shifter is + // process()'d EXACTLY ONCE per output frame (never twice — that would advance its + // heads twice and corrupt the OLA state). + shiftR_.setShiftRatio(shift); + outRlocal = + static_cast(shiftR_.process(static_cast(srcR))) * gain; + } else { + // Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted + // value from srcL (== srcR since pcmR aliases pcm); mirror it to R. Do NOT call + // shiftL_.process again this frame. + outRlocal = shiftedL * gain; + } + } + // Preserve advances the read head at the SOURCE rate (duration preserved). + ratio_ = 1.0; + } else { + // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch + // envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the + // envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical). + outL = srcL * gain; + if (stereo) outRlocal = srcR * gain; + ratio_ = baseRatio_ * envFactor; + } + + if (stereo) outR = static_cast(outRlocal); + readPos_ += ratio_; - if (env_.finished()) { + if (amplitudeDone_) { active_ = false; } return static_cast(outL); @@ -262,10 +480,27 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { // --------------------------------------------------------------------------- VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, - const AdsrParams& adsr) - : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr) { + const AdsrParams& adsr, std::size_t preserveVoiceCap, + std::int64_t preserveWindowFrames) + : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr), + preserveVoiceCap_(preserveVoiceCap) { // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; // clamp to a single voice so the engine is always usable (documented degenerate). + // + // Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so + // note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one + // allocation point for the shifter rings across the engine's lifetime. + if (preserveWindowFrames > 1) { + for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames); + } +} + +std::size_t VoiceEngine::activePreserveVoices() const { + std::size_t n = 0; + for (const Voice& v : voices_) { + if (v.active() && v.pitchEngine() == PitchEngine::Preserve) ++n; + } + return n; } std::size_t VoiceEngine::allocateVoice() { @@ -305,6 +540,18 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) { } const SampleData& sample = keymap_.samples[zone.sampleIndex]; + // S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice + // OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on + // rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated). + // Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only + // NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch"). + if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve && + activePreserveVoices() >= preserveVoiceCap_) { + return kNoVoice; + } + + // The voice's Preserve shifters were pre-sized at engine construction (off-thread), so + // start() only reset()s + warm()s them — no allocation on this audio-thread path. const std::size_t v = allocateVoice(); voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); voices_[v].setStartOrder(nextStartOrder_++); diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index ece219d..28d99d5 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -21,7 +21,8 @@ #include #include -#include "peaks.h" // AudioSample (float) +#include "peaks.h" // AudioSample (float) +#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) namespace reasampler { @@ -33,6 +34,92 @@ namespace reasampler { // itself never branches on it — the mode only picks which render overload the shell drives. enum class ChannelMode { Mono, Stereo }; +// --------------------------------------------------------------------------- +// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because +// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching +// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower +// with the rest of the engine machinery; only the value structs need to precede SampleData. +// --------------------------------------------------------------------------- + +// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack +// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's +// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, +// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone +// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. +enum class PlayMode { Gate, Trigger }; + +// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span +// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), +// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over +// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play +// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. +struct TriggerParams { + double lengthFraction = 1.0; // (0,1] of the post-start span to play + std::int64_t fadeInFrames = 0; // 0->1 ramp at the head + std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd +}; + +// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default +// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) +// so a third curve can join without a signature change. +enum class FadeCurve { EqualPower, Linear }; + +// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. +inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; + +// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration +// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances +// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). +enum class PitchEngine { Varispeed, Preserve }; + +// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" +// directive). ONE constant to flip if Varispeed should be the default instead. This is the +// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's +// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core +// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 +// engine" holds for the core's own regression tests (an octave up still halves duration in the +// bare engine); the Preserve product default is layered on above at (de)serialization. +inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; + +// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds +// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = +// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation. +inline constexpr double kPreserveWindowMs = 50.0; + +// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always +// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to +// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack +// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). +struct PitchEnvParams { + bool enabled = false; + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData +// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 +// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope +// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the +// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one +// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. +struct ZonePlayParams { + PlayMode playMode = PlayMode::Gate; + AdsrParams adsr; // Gate: the AHDSR envelope + TriggerParams trigger; // Trigger: %-length + fades + PitchEngine pitchEngine = PitchEngine::Varispeed; + PitchEnvParams pitchEnv; // AD pitch modulation, off by default +}; + // --------------------------------------------------------------------------- // Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that // govern playback. The shell decodes the on-disk WAV and fills this; the core @@ -69,6 +156,11 @@ struct SampleData { // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. std::int64_t startFrame = 0; + // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch + // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is + // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. + ZonePlayParams play; + // 2 iff a matching-length second channel exists; else 1. A framesR of a different // length than frames is treated as absent (mono) — a malformed pair never half-plays. int channelCount() const { @@ -134,30 +226,26 @@ struct Keymap { double pitchRatio(int note, int rootNote); // --------------------------------------------------------------------------- -// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate: -// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic -// four-stage shape, asserted against a known signal in the tests (mirror of peaks). +// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based +// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters +// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks). // // Segment math (all linear ramps): // Attack: 0 -> 1 over attackFrames +// Hold: hold 1 over holdFrames (S15: NEW stage between A and D) // Decay: 1 -> sustainLevel over decayFrames // Sustain: hold sustainLevel until noteOff // Release: currentLevel -> 0 over releaseFrames -// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to -// sustain; a noteOff during attack/decay (release-before-sustain) releases from the -// current partial level, not from sustainLevel. +// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold +// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged); +// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain) +// releases from the current partial level, not from sustainLevel. AdsrParams is defined above +// (with the other per-zone value structs); this section holds only the per-frame evaluator. // --------------------------------------------------------------------------- -struct AdsrParams { - std::int64_t attackFrames = 0; - std::int64_t decayFrames = 0; - double sustainLevel = 1.0; // 0..1 - std::int64_t releaseFrames = 0; -}; - class AdsrEnvelope { public: - enum class Stage { Idle, Attack, Decay, Sustain, Release, Finished }; + enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished }; void configure(const AdsrParams& params) { params_ = params; } @@ -184,6 +272,64 @@ private: double releaseFrom_ = 0.0; // level at the moment noteOff() was called }; +// --------------------------------------------------------------------------- +// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams / +// FadeCurve value structs are defined above with the other per-zone params. +// --------------------------------------------------------------------------- + +// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated +// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output +// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so +// output and source frames coincide, but under Varispeed a transposed voice consumes source +// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME +// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The +// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by +// the play length and note-off-immune. Reports finished() once the offset reaches the play length. +class TriggerEnvelope { +public: + // Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the + // SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength + // (fadeOut anchored to the end). A zero/negative play length finishes immediately. + void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, + std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve); + + // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play + // span. Latches finished() once the offset reaches the play length (>= playLength). Pure over + // the offset (no internal advance) so it composes with either pitch engine's read rate. + double amplitudeAt(double sourceOffset); + + bool finished() const { return finished_; } + +private: + std::int64_t playLength_ = 0; + std::int64_t fadeIn_ = 0; + std::int64_t fadeOut_ = 0; + FadeCurve curve_ = kDefaultFadeCurve; + bool finished_ = false; +}; + +// --------------------------------------------------------------------------- +// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs +// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above. +// --------------------------------------------------------------------------- + +// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones +// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone +// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested +// for offset at t=0, peak at t=attack, and 0 at t=attack+decay. +class PitchEnvelope { +public: + void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } + void noteOn() { pos_ = 0; } + + // Advance one frame, return this frame's pitch offset in semitones. + double tick(); + +private: + PitchEnvParams params_; + std::int64_t pos_ = 0; +}; + // --------------------------------------------------------------------------- // A single voice: one active note playing one repitched, enveloped sample. Reads // the sample by fractional frame position with linear interpolation, advancing by @@ -193,13 +339,21 @@ private: class Voice { public: // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference - // the caller must keep alive for the voice's lifetime — the Keymap owns it), - // repitched from `rootNote`, with `adsr` as the amplitude envelope. + // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched + // from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the + // instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from + // sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the + // S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST + // already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s + // them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm + // silence pass settles the OLA taps before the first output frame (no cold-start click). + // Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no + // pitch env). void start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& adsr); + const AdsrParams& gateAdsr); - // Gate off — begins the amplitude release. The voice keeps rendering (and looping, - // if it would) until the release finishes, then goes idle. + // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in + // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). void release(); // True while this voice is producing (or about to produce) sound. @@ -211,6 +365,17 @@ public: std::uint64_t startOrder() const { return startOrder_; } void setStartOrder(std::uint64_t order) { startOrder_ = order; } bool releasing() const { return releasing_; } + // The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only + // meaningful while active(). + PitchEngine pitchEngine() const { return pitchEngine_; } + + // Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the + // audio thread (this allocates). The engine calls it once at construction so start() — which + // runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s + // the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed + // instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op + // in the underlying vector. + void presizePreserveShifters(std::int64_t windowFrames); // Renders one frame's contribution, advancing the read head and envelope by one // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the @@ -229,19 +394,46 @@ public: private: // Shared read/advance for both render paths: computes the interpolated per-channel - // value(s) at the current read head, ticks the envelope once, advances the head, and + // value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies + // the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and // latches idle on exhaustion. `stereo` selects whether the second channel is read (and // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value. AudioSample advanceFrame(bool stereo, AudioSample& outR); + // This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per + // output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the + // fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to + // source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope + // finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice. + double tickAmplitude(); + bool active_ = false; bool releasing_ = false; int note_ = 0; double velocityGain_ = 1.0; - double ratio_ = 1.0; // fractional frames advanced per output frame + double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio + double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; + + // S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only + // one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame + // stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle). + PlayMode playMode_ = PlayMode::Gate; AdsrEnvelope env_; + TriggerEnvelope trigEnv_; + std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin + std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused + bool amplitudeDone_ = false; // set when the active amplitude envelope finished + + // S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve + // (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel + // (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine. + PitchEngine pitchEngine_ = PitchEngine::Varispeed; + PitchEnvelope pitchEnv_; + PitchShifter shiftL_; + PitchShifter shiftR_; + std::uint64_t startOrder_ = 0; }; @@ -262,8 +454,19 @@ class VoiceEngine { public: // Builds an engine with `maxVoices` voices (the polyphony bound) playing from // `keymap`. The keymap must outlive the engine (the engine holds a reference — it - // reads zones and sample data through it, never copies PCM). - VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr); + // reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide + // Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode + + // pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many + // Preserve-engine voices may sound at once (the shifter is materially heavier than + // Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means + // "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA + // window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at + // construction (OFF the audio thread), so note-on (which runs in process()) never allocates; + // 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor + // derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers + // (and the pure-core tests) are unaffected. + VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr, + std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0); // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of // zone) it is a defined no-op (no voice consumed). Otherwise allocates a free @@ -313,9 +516,14 @@ private: // one per the documented policy. Always returns a valid index (maxVoices >= 1). std::size_t allocateVoice(); + // Count of active Preserve-engine voices (for the S16 Preserve cap). A cheap running tally + // kept in sync at note-on/steal/free rather than rescanned per note. + std::size_t activePreserveVoices() const; + std::vector voices_; const Keymap& keymap_; AdsrParams adsr_; + std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap) std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" }; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp new file mode 100644 index 0000000..aca05d6 --- /dev/null +++ b/tests/test_pitch_shift.cpp @@ -0,0 +1,187 @@ +// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3, +// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL +// chain is the CMake target linking only pitch_shift (+ peaks). +// +// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve): +// 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing +// Preserve property: a transposed render is the SAME frame length as the un-transposed one). +// 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity +// must not mangle the signal). +// 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an +// octave-down lowers it (period lengthens), measured on a synthesized sine. +// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long +// process() run never resizes the ring (checked via window() constancy) and never returns +// NaN/inf; pass-through (unconfigured) returns input verbatim. + +#include "../src/vst/pitch_shift.h" + +#include +#include +#include + +using namespace reasampler; + +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 bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } + +constexpr double kPi = 3.14159265358979323846; + +// A sine of `cycles` periods over `frames` frames. +static std::vector sine(std::size_t frames, double cycles) { + std::vector s(frames); + for (std::size_t i = 0; i < frames; ++i) { + s[i] = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + } + return s; +} + +// Average spacing between positive-going zero crossings (the observed period). +static double observedPeriod(const std::vector& out, std::size_t from) { + std::vector up; + for (std::size_t i = from + 1; i < out.size(); ++i) { + if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i); + } + if (up.size() < 2) return 0.0; + double sum = 0.0; + for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast(up[i] - up[i - 1]); + return sum / static_cast(up.size() - 1); +} + +// --- 1. Duration invariance across shift ratios. --- +static void testDurationInvariance() { + // The core Preserve property: whatever the shift ratio, one input frame yields one output + // frame. So a shifter fed N frames produces exactly N frames — a transposed render is the + // same length as an un-transposed one (unlike Varispeed, where an octave up halves length). + const std::size_t n = 4000; + const std::vector in = sine(n, 40.0); + const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)}; + for (double r : ratios) { + PitchShifter ps; + ps.configure(2205); // ~50 ms @ 44.1k + ps.warm(); + ps.setShiftRatio(r); + std::size_t produced = 0; + for (std::size_t i = 0; i < n; ++i) { + const AudioSample o = ps.process(in[i]); + (void)o; + ++produced; // exactly one output per input, unconditionally. + } + CHECK(produced == n); // duration held at every ratio. + } +} + +// --- 2. Unity pass-through fidelity. --- +static void testUnityRoughlyReproduces() { + // At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run + // in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the + // observed period must match the source period within a small tolerance past the warm-up. + const std::size_t n = 8000; + const double cycles = 40.0; + const double nativePeriod = static_cast(n) / cycles; // 200 + const std::vector in = sine(n, cycles); + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(1.0); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + // Measure past the initial half-window latency region. + const double p = observedPeriod(out, 3000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod, nativePeriod * 0.10)); // within 10% of source period +} + +// --- 3. Transpose direction: up shortens the period, down lengthens it. --- +static void testTransposeDirection() { + const std::size_t n = 12000; + const double cycles = 60.0; + const double nativePeriod = static_cast(n) / cycles; // 200 + const std::vector in = sine(n, cycles); + + // Octave up: output period ~ half the source period (higher pitch). + { + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(2.0); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + const double p = observedPeriod(out, 4000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15)); // period halves + } + // Octave down: output period ~ double the source period (lower pitch). + { + PitchShifter ps; + ps.configure(2205); + ps.warm(); + ps.setShiftRatio(0.5); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]); + const double p = observedPeriod(out, 4000); + CHECK(p > 0.0); + CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30)); // period doubles + } +} + +// --- 4. RT discipline surrogate + pass-through. --- +static void testRtDisciplineAndPassthrough() { + // Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one). + { + PitchShifter ps; + CHECK(!ps.configured()); + CHECK(ps.process(0.37f) == 0.37f); // exact pass-through + CHECK(ps.process(-0.9f) == -0.9f); + } + // Configured: the window is fixed at configure() and never changes across a long run (no + // per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA). + { + PitchShifter ps; + ps.configure(1024); + ps.warm(); + const std::int64_t w = ps.window(); + CHECK(w == 1024); + ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0)); + const std::vector in = sine(20000, 100.0); + for (std::size_t i = 0; i < in.size(); ++i) { + const AudioSample o = ps.process(in[i]); + CHECK(std::isfinite(o)); + } + CHECK(ps.window() == w); // window unchanged -> ring never resized mid-run + } + // A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses. + { + PitchShifter ps; + ps.configure(512); + ps.warm(); + ps.setShiftRatio(1.0); + ps.setShiftRatio(-2.0); // ignored + ps.setShiftRatio(0.0); // ignored + for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f))); + } + // Degenerate window (<= 1) stays pass-through even after configure. + { + PitchShifter ps; + ps.configure(1); + CHECK(!ps.configured()); + CHECK(ps.process(0.25f) == 0.25f); + } +} + +int main() { + testDurationInvariance(); + testUnityRoughlyReproduces(); + testTransposeDirection(); + testRtDisciplineAndPassthrough(); + + if (g_fail == 0) { + std::printf("all pitch_shift tests passed\n"); + return 0; + } + std::printf("%d pitch_shift check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 7763915..a238713 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -979,6 +979,113 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { !back.map.zones[1].startPoint.has_value()); } +// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- + +static void testPlayParamsRoundTrip() { + // A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine, + // pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail. + PerformanceMap m; + PerformanceZone z = zone("lead", 20, 100, /*override=*/55); + z.play.playMode = PlayMode::Trigger; + z.play.adsr.holdFrames = 1234; + z.play.trigger.lengthFraction = 0.375; + z.play.trigger.fadeInFrames = 64; + z.play.trigger.fadeOutFrames = 128; + z.play.pitchEngine = PitchEngine::Varispeed; + z.play.pitchEnv.enabled = true; + z.play.pitchEnv.attackFrames = 10; + z.play.pitchEnv.decayFrames = 500; + z.play.pitchEnv.peakSemitones = -7.5; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const ZonePlayParams& p = back.zones[0].play; + CHECK(p.playMode == PlayMode::Trigger); + CHECK(p.adsr.holdFrames == 1234); + CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip + CHECK(p.trigger.fadeInFrames == 64); + CHECK(p.trigger.fadeOutFrames == 128); + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled == true); + CHECK(p.pitchEnv.attackFrames == 10); + CHECK(p.pitchEnv.decayFrames == 500); + CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip +} + +static void testPlayParamsComposeWithLoopStart() { + // S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together. + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 60); + SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222; + z.loopOverride = lp; + z.startPoint = 333; + z.play.playMode = PlayMode::Gate; + z.play.adsr.holdFrames = 999; + z.play.pitchEngine = PitchEngine::Preserve; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].loopOverride.has_value() && + back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); + CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); + CHECK(back.zones[0].play.adsr.holdFrames == 999); + CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); +} + +static void testPlayParamsV2BackCompatLiftsToDefaults() { + // A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail) + // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the + // deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version (2) + u32(kZonesFormatMarker); // marker -> a versioned payload + u32(2); // PAYLOAD VERSION 2 (S11, no play tail) + u32(1); // zone count 1 + const std::string id = "old"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(5); // lowNote + u32(80); // highNote + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 (record ends here in v2) + const PerformanceMap back = deserializePerformance(b); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].sampleId == "old"); + // Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off. + CHECK(back.zones[0].play.playMode == PlayMode::Gate); + CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve + CHECK(back.zones[0].play.pitchEnv.enabled == false); + CHECK(back.zones[0].play.adsr.holdFrames == 0); +} + +static void testPlayParamsThroughComponentEnvelope() { + // The play params round-trip through the v4 COMPONENT envelope too (the composition property: + // the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them). + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + PerformanceZone z = zone("z", 0, 127); + z.play.playMode = PlayMode::Trigger; + z.play.trigger.lengthFraction = 0.9; + z.play.pitchEngine = PitchEngine::Varispeed; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.channelMode == ChannelMode::Stereo); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger); + CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9); + CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1026,6 +1133,10 @@ int main() { testPerformanceStateV1BackCompat(); testPerformanceStateGarbage(); testPerformanceStateNegativeNotesRoundTrip(); + testPlayParamsRoundTrip(); + testPlayParamsComposeWithLoopStart(); + testPlayParamsV2BackCompatLiftsToDefaults(); + testPlayParamsThroughComponentEnvelope(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 8d25484..b7282a4 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -829,6 +829,354 @@ static void testStereoStartFrameLoopShareOneReadHead() { } } +// =========================================================================== +// S15 — sampling modes (Gate AHDSR hold stage, Trigger %-length + fades, note-off immunity). +// =========================================================================== + +// --- AHDSR hold stage vs a known signal. --- +static void testAhdsrHoldStageShape() { + // Gate grows a HOLD stage between Attack and Decay: attack 0->1 (5f), HOLD at 1.0 (8f), + // decay 1->0.5 (5f), sustain 0.5. Assert the hold plateau is exactly 1.0 for holdFrames. + AdsrParams p; + p.attackFrames = 5; + p.holdFrames = 8; + p.decayFrames = 5; + p.sustainLevel = 0.5; + p.releaseFrames = 5; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + for (int i = 0; i < 5; ++i) env.tick(); // consume Attack (ends at 1.0) + // The next holdFrames ticks must all be exactly 1.0 (the plateau), stage == Hold. + for (int i = 0; i < 8; ++i) { + CHECK(env.stage() == AdsrEnvelope::Stage::Hold); + CHECK(approx(env.tick(), 1.0, 1e-9)); + } + // Then Decay begins, falling from 1.0 toward sustain 0.5. + CHECK(env.stage() == AdsrEnvelope::Stage::Decay); + double v = env.tick(); + CHECK(v <= 1.0 + 1e-9 && v >= 0.5 - 1e-9); +} + +// --- hold == 0 is byte-identical to the pre-S15 ADSR (back-compat regression). --- +static void testAhdsrHoldZeroEqualsAdsr() { + // The load-bearing back-compat guarantee: hold=0 reproduces the classic ADSR frame-for-frame. + // Assert against a HAND-COMPUTED expected sequence (not another envelope — that would be + // tautological). attack 4, hold 0, decay 4, sustain 0.5. Expected per-tick output: + // Attack: 0/4, 1/4, 2/4, 3/4 (ticks 0..3, level rising 0 -> 0.75) + // Decay: 1.0, then 1.0+(0.5-1)*t for t=1/4..3/4 (ticks 4..7: 1.0, 0.875, 0.75, 0.625) + // Sustain: 0.5 forever (tick 8+) + AdsrParams p; + p.attackFrames = 4; + p.holdFrames = 0; // the degenerate — must NOT insert an extra unity frame + p.decayFrames = 4; + p.sustainLevel = 0.5; + p.releaseFrames = 4; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + const double expected[] = {0.0, 0.25, 0.5, 0.75, // attack + 1.0, 0.875, 0.75, 0.625, // decay (first sample 1.0 at t=0) + 0.5, 0.5, 0.5}; // sustain + for (double e : expected) CHECK(approx(env.tick(), e, 1e-9)); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); // reached sustain at the SAME tick count +} + +// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope +// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp. +static SampleData triggerSample(std::size_t frames, double lengthFraction, + std::int64_t fadeIn, std::int64_t fadeOut, + std::int64_t startFrame = 0) { + SampleData s = dcSample(frames, 60); + s.startFrame = startFrame; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch + s.play.trigger.lengthFraction = lengthFraction; + s.play.trigger.fadeInFrames = fadeIn; + s.play.trigger.fadeOutFrames = fadeOut; + return s; +} + +// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. --- +static void testTriggerLengthFractionFrames() { + // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio + std::vector out; + eng.render(out, 200); + // First 100 frames sound (amp>0 for a no-fade trigger = 1.0), then silence + voice freed. + for (std::size_t i = 0; i < 100; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 100; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); // ran off playEnd +} + +// --- Trigger start point: %-length measured from the start offset. --- +static void testTriggerLengthWithStart() { + // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); + for (std::size_t i = 0; i < 80; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 80; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); +} + +// --- Trigger fade-in / fade-out ramp shape (equal-power default). --- +static void testTriggerFadeShape() { + // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity + // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. + Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 120); + CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0 + // Fade-in monotonic non-decreasing. + for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4); + // Unity plateau in the middle. + for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3)); + // Fade-out monotonic non-increasing over [80,100). + for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4); + // Past playEnd = silence. + for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +// --- Trigger edge cases: %=0 (immediate free) and fades overlapping (clamped). --- +static void testTriggerEdgeCases() { + // %=0: zero play length -> voice frees at once, no sound. + { + Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 50); + for (float v : out) CHECK(approx(v, 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); + } + // Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]). + { + // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. + Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 50); + for (std::size_t i = 0; i < 40; ++i) CHECK(out[i] >= -1e-4 && out[i] <= 1.0 + 1e-4); + CHECK(eng.activeVoiceCount() == 0); + } + // %=100 plays the full post-start span. + { + Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 80); + for (std::size_t i = 0; i < 60; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 60; i < 80; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + } +} + +// --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- +static void testTriggerIgnoresNoteOff() { + Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 10); + eng.noteOff(60); // must be a NO-OP in Trigger + CHECK(eng.activeVoiceCount() == 1); // still sounding after note-off + eng.render(out, 200); + // It still plays its full 100-frame length (frames 10..99 remain > 0 after the note-off). + for (std::size_t i = 10; i < 100; ++i) CHECK(out[i] > 0.5f); + for (std::size_t i = 100; i < 210; ++i) CHECK(approx(out[i], 0.0, 1e-6)); + CHECK(eng.activeVoiceCount() == 0); // frees on its own playEnd, not on note-off +} + +// =========================================================================== +// S16 — pitch engine (Preserve duration invariance) + pitch envelope (off = identical). +// =========================================================================== + +// Render one note to completion (or `maxFrames`) and return the frame count at which the voice +// went idle (the audible LENGTH). A Gate note with a short release + a finite sample runs off. +static std::size_t soundingLength(VoiceEngine& eng, std::size_t maxFrames) { + std::vector out; + std::size_t len = 0; + for (std::size_t f = 0; f < maxFrames; ++f) { + eng.render(out, 1); + if (eng.activeVoiceCount() > 0) len = f + 1; + else break; + } + return len; +} + +// A Preserve-engine one-shot Trigger sample: under Preserve, the %-length wall-clock is stable +// under transpose (the S15xS16 contract). Trigger + Preserve isolates the length measurement from +// Gate's release tail. +static SampleData preserveTriggerSample(std::size_t frames, double lengthFraction) { + SampleData s = dcSample(frames, 60); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Preserve; + s.play.trigger.lengthFraction = lengthFraction; + return s; +} + +// --- Preserve duration invariance: same note length across +/-12 semitones. --- +static void testPreserveDurationInvariance() { + // A Preserve Trigger at 100% length of a 1000-frame sample plays ~1000 output frames + // regardless of transpose (duration held). Under Varispeed an octave up would halve it. + const std::size_t frames = 1000; + const std::size_t window = 512; // pre-size the shifters + + auto lengthAt = [&](int note) -> std::size_t { + Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0)); + VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast(window)); + eng.noteOn(note, 127); + return soundingLength(eng, 4000); + }; + + const std::size_t atRoot = lengthAt(60); + const std::size_t atUp = lengthAt(72); // +12 + const std::size_t atDown = lengthAt(48); // -12 + // All three within a small tolerance of the source length (Preserve holds duration). The + // tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x). + CHECK(atRoot >= frames - 20 && atRoot <= frames + 20); + CHECK(atUp >= frames - 20 && atUp <= frames + 20); + CHECK(atDown >= frames - 20 && atDown <= frames + 20); + // The decisive assertion: the up/down lengths track the root length (NOT halved/doubled). + CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would) + CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it +} + +// --- Varispeed still couples duration (the contrast to Preserve — regression on the old default). --- +static void testVarispeedStillCouplesDuration() { + // A Varispeed Trigger octave up runs off in ~half the frames (pitch & duration coupled). + auto lengthAt = [&](int note) -> std::size_t { + SampleData s = dcSample(1000, 60); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigger.lengthFraction = 1.0; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(note, 127); + return soundingLength(eng, 4000); + }; + const std::size_t atRoot = lengthAt(60); + const std::size_t atUp = lengthAt(72); + CHECK(approx(static_cast(atUp), static_cast(atRoot) / 2.0, 30.0)); +} + +// --- Pitch envelope OFF == bit-identical to the un-modulated engine (regression). --- +static void testPitchEnvOffBitIdentical() { + // Two Varispeed voices, one with a disabled pitch env, one with no pitch env at all. Their + // rendered output must be BIT-IDENTICAL (pitch-env-off applies zero modulation — the S16 + // "identical to pre-S16" guarantee). Uses a sine so any pitch drift would show as phase drift. + const std::size_t n = 4000; + auto renderOne = [&](bool withDisabledEnv) -> std::vector { + SampleData s = sineSample(n, 20.0, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + if (withDisabledEnv) { + s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0) + s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled + s.play.pitchEnv.attackFrames = 0; + s.play.pitchEnv.decayFrames = 500; + } + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path) + std::vector out; + eng.render(out, n); + return out; + }; + const std::vector a = renderOne(false); + const std::vector b = renderOne(true); + CHECK(a.size() == b.size()); + bool identical = a.size() == b.size(); + for (std::size_t i = 0; i < a.size() && identical; ++i) { + if (a[i] != b[i]) identical = false; + } + CHECK(identical); // disabled pitch env produces the EXACT same samples (no modulation) +} + +// --- Pitch envelope ON biases pitch (Varispeed): a positive-peak zero-attack env starts sharp. --- +static void testPitchEnvOnBendsVarispeed() { + // Zero attack + positive peak = "start high, drop to base": the note begins transposed UP and + // settles. Observe the read advancing FASTER at the start (period shorter early) than late. + const std::size_t n = 8000; + SampleData s = sineSample(n, 40.0, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.attackFrames = 0; // start at the peak + s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames + s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend + std::vector out; + eng.render(out, 4000); + // Early period (heavily transposed up) should be shorter than the late period (settled). + std::vector early(out.begin(), out.begin() + 800); + std::vector late(out.begin() + 3200, out.begin() + 4000); + const double pe = observedPeriodFrames(early); + const double pl = observedPeriodFrames(late); + CHECK(pe > 0.0 && pl > 0.0); + CHECK(pe < pl); // pitch dropped over time (period lengthened) -> the AD env bent the pitch +} + +// --- Compose: engine x mode x stereo x loop (a Preserve Gate loop in stereo sounds + sustains). --- +static void testPreserveGateStereoLoopComposes() { + // A STEREO sample, GATE mode, PRESERVE engine, with a sustain loop. It must sound on BOTH + // channels and sustain (the loop keeps the voice alive) — S7 x S15 x S16 all composing. + SampleData s; + const std::size_t frames = 400; + s.frames.resize(frames); + s.framesR.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + const float v = static_cast(std::sin(2.0 * kPi * 8.0 * + static_cast(i) / static_cast(frames))); + s.frames[i] = v; + s.framesR[i] = v * 0.5f; // R is a distinct (half-amplitude) channel + } + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 100; + s.loop.end = 300; + s.play.playMode = PlayMode::Gate; + s.play.pitchEngine = PitchEngine::Preserve; + CHECK(s.channelCount() == 2); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr(), 0, 512); + eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held) + std::vector left(2000, 0.f), right(2000, 0.f); + eng.render(left.data(), right.data(), 2000); + // The loop sustains the voice well past the sample length (400 frames) -> still active. + CHECK(eng.activeVoiceCount() == 1); + // Both channels carry signal (some frame has non-trivial magnitude on each). + double maxL = 0.0, maxR = 0.0; + for (std::size_t i = 600; i < 2000; ++i) { + if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]); + if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]); + } + CHECK(maxL > 0.05); + CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved +} + +// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- +static void testPreserveVoiceCap() { + SampleData s = dcSample(2000, 60); + s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + // 8 voices total, Preserve cap of 2. + VoiceEngine eng(8, km, flatAdsr(), /*preserveCap=*/2, /*window=*/256); + CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice + CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) + CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap + CHECK(eng.activeVoiceCount() == 2); +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -863,6 +1211,23 @@ int main() { testStereoRenderNullBufferIsNoOp(); testStereoStartFrameLoopShareOneReadHead(); + // S15 — sampling modes. + testAhdsrHoldStageShape(); + testAhdsrHoldZeroEqualsAdsr(); + testTriggerLengthFractionFrames(); + testTriggerLengthWithStart(); + testTriggerFadeShape(); + testTriggerEdgeCases(); + testTriggerIgnoresNoteOff(); + + // S16 — pitch engine + pitch envelope. + testPreserveDurationInvariance(); + testVarispeedStillCouplesDuration(); + testPitchEnvOffBitIdentical(); + testPitchEnvOnBendsVarispeed(); + testPreserveGateStereoLoopComposes(); + testPreserveVoiceCap(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; From 5dcd5d0d9de67458e8c51f4e7e8afdcacc9743fe Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 00:02:57 -0400 Subject: [PATCH 2/2] fix(comments): correct two misleading impl comments in sampler_core (rescan vs tally; re-dispatch vs recursion) --- src/vst/sampler_core.cpp | 2 +- src/vst/sampler_core.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 88de67e..4aa2f16 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -104,7 +104,7 @@ double AdsrEnvelope::tick() { // Fall through to Decay this frame so no extra unity sample is emitted for a // zero-length hold (preserving the exact pre-S15 sample-for-sample shape). level_ = 1.0; - // Re-dispatch by recursion-free goto-equivalent: evaluate Decay immediately. + // Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion). return tick(); } level_ = 1.0; diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 28d99d5..b4a09d7 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -516,8 +516,8 @@ private: // one per the documented policy. Always returns a valid index (maxVoices >= 1). std::size_t allocateVoice(); - // Count of active Preserve-engine voices (for the S16 Preserve cap). A cheap running tally - // kept in sync at note-on/steal/free rather than rescanned per note. + // Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on + // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; std::vector voices_;