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.
This commit is contained in:
2026-07-26 23:50:31 -04:00
parent 725f3e7d3c
commit 1e1d6bddbb
13 changed files with 1528 additions and 77 deletions
+1
View File
@@ -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. | | `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_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. | | `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`). | | `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
### Beta channel build (Phase V, V4) ### Beta channel build (Phase V, V4)
+17 -1
View File
@@ -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/ # 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. # 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 <windows.h> (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) add_library(sampler_core STATIC src/vst/sampler_core.cpp)
target_include_directories(sampler_core PUBLIC src src/vst) 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). # 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 # 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 # 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). # 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 <windows.h> 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) add_executable(sampler_core_tests tests/test_sampler_core.cpp)
target_link_libraries(sampler_core_tests PRIVATE sampler_core) target_link_libraries(sampler_core_tests PRIVATE sampler_core)
add_test(NAME sampler_core_tests COMMAND sampler_core_tests) add_test(NAME sampler_core_tests COMMAND sampler_core_tests)
+126
View File
@@ -0,0 +1,126 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2
// route-(b) rationale (WDL drags <windows.h>, 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 <algorithm>
#include <cmath>
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<std::size_t>(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<double>(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<std::size_t>(writePos_)] = in;
const double w = static_cast<double>(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<std::int64_t>(p);
const double frac = p - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (i1 >= window_) i1 = 0;
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(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<double>(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<AudioSample>(out);
}
} // namespace reasampler
+88
View File
@@ -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 <windows.h>` 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 <cstddef>
#include <cstdint>
#include <vector>
#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<AudioSample> 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
+15 -1
View File
@@ -41,6 +41,13 @@ constexpr double kSustainLevel = 1.0;
constexpr double kReleaseSeconds = 0.060; constexpr double kReleaseSeconds = 0.060;
constexpr std::size_t kMaxVoices = 16; 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) { AdsrParams tier0Adsr(double sampleRate) {
const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; const double sr = sampleRate > 0.0 ? sampleRate : 44100.0;
AdsrParams p; AdsrParams p;
@@ -363,8 +370,15 @@ std::string ReaSamplerProcessor::reloadFromBank() {
} }
if (haveKeymap) { 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<std::int64_t>(
kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>( built = std::make_unique<LoadedInstrument>(
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap,
preserveWindow);
} }
} }
+5 -2
View File
@@ -51,8 +51,11 @@ struct LoadedInstrument {
std::uint64_t installedAt = 0; // reload generation at which this was installed std::uint64_t installedAt = 0; // reload generation at which this was installed
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr,
std::uint64_t gen) std::uint64_t gen, std::size_t preserveVoiceCap = 0,
: keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {} std::int64_t preserveWindowFrames = 0)
: keymap(std::move(km)),
engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames),
installedAt(gen) {}
LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete; LoadedInstrument& operator=(const LoadedInstrument&) = delete;
+51 -2
View File
@@ -135,7 +135,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate, Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop, int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR) { std::vector<AudioSample> framesR, const ZonePlayParams& play) {
SampleData data; SampleData data;
data.frames = std::move(frames); data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays // A second channel only counts when it length-matches channel 0 (else the sample stays
@@ -146,6 +146,7 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
data.rootNote = rootNote; data.rootNote = rootNote;
data.loop = loop; data.loop = loop;
data.play = play; // S15/S16 single-capture play params (product defaults unless overridden)
return Keymap::singleSampleChromatic(std::move(data)); 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). // never mutated — this only shapes what the core plays for THIS instance (D-B).
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
rz.startFrame = z.startPoint ? *z.startPoint : 0; 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)); out.zones.push_back(std::move(rz));
} }
return out; return out;
@@ -210,6 +214,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
data.rootNote = zones[i].rootNote; data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop; data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) 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(); const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data)); km.samples.push_back(std::move(data));
KeyZone zone; KeyZone zone;
@@ -241,6 +246,19 @@ void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); } std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(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 // 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 // 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. // blob degrades to a partial/empty parse rather than reading out of bounds.
@@ -324,6 +342,20 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
} }
out.push_back(z.startPoint ? 1 : 0); out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint)); 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<std::uint8_t>& out, const PerformanceMap& map)
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest. // keeps the zones that parsed cleanly and drops the rest.
void readZonesPayload(ByteReader& r, PerformanceMap& map) { 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) { if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker r.u32(); // consume the marker
const std::uint32_t pv = r.u32(); // payload version const std::uint32_t pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail 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(); const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) { 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; PerformanceZone z;
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen); z.sampleId = r.str(idLen);
@@ -360,6 +396,19 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
const std::uint8_t hasStart = r.u8(); const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64(); 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 if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z)); map.zones.push_back(std::move(z));
} }
+43 -7
View File
@@ -114,9 +114,16 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // 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 // mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
// pair never half-plays. `sampleRate` is the WAV's rate. // 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<AudioSample> frames, int sampleRate, Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop, int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {}); std::vector<AudioSample> framesR = {},
const ZonePlayParams& play = ZonePlayParams{
PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine,
PitchEnvParams{}});
// --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
// //
@@ -147,6 +154,17 @@ struct PerformanceZone {
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0 std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// 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 // 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 int rootNote = 60; // effective: override, else bank intrinsic, else 60
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (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 // The result of resolving a performance map against the live bank blob. `zones` are the
@@ -249,6 +269,20 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // 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 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. // 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 // 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 // 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 // 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<AudioSample>& interleaved,
inline constexpr std::uint32_t kPerformanceStateVersion = 2; inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11). serializePerformance and // The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance
// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the // and serializeComponentState both emit the CURRENT payload version (v3 — marker + version +
// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload // records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides
// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count // round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload
// (bounded by 128 MIDI zones in practice, always tiny) can never collide with. // (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults.
inline constexpr std::uint32_t kZonesPayloadVersion = 2; // 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; inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// The performance map serialized to bytes for IBStream (getState). // The performance map serialized to bytes for IBStream (getState).
+286 -39
View File
@@ -85,6 +85,32 @@ double AdsrEnvelope::tick() {
const double out = level_; const double out = level_;
++framesInStage_; ++framesInStage_;
if (framesInStage_ >= params_.attackFrames) { 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; stage_ = Stage::Decay;
framesInStage_ = 0; framesInStage_ = 0;
level_ = 1.0; level_ = 1.0;
@@ -136,46 +162,194 @@ double AdsrEnvelope::tick() {
return 0.0; // unreachable; silences a warning. 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<double>(playLength_)) {
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
if (sourceOffset >= static_cast<double>(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<double>(playLength_ - fadeOut_);
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
const double phase = sourceOffset / static_cast<double>(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<double>(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<double>(pos_) / static_cast<double>(a));
} else if (pos_ < a + d) {
// Decay: peak -> 0 over decayFrames (settle to base pitch).
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
offset = peak * (1.0 - t);
} else {
offset = 0.0; // past attack+decay: at base pitch forever.
}
++pos_;
return offset;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Voice // 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, void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
const AdsrParams& adsr) { const AdsrParams& gateAdsr) {
active_ = true; active_ = true;
releasing_ = false; releasing_ = false;
amplitudeDone_ = false;
note_ = note; note_ = note;
// MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively.
int v = velocity; int v = velocity;
if (v < 0) v = 0; if (v < 0) v = 0;
if (v > 127) v = 127; if (v > 127) v = 127;
velocityGain_ = static_cast<double>(v) / 127.0; velocityGain_ = static_cast<double>(v) / 127.0;
ratio_ = pitchRatio(note, rootNote); baseRatio_ = pitchRatio(note, rootNote);
// Initial read position honors the sample's start-point offset (S11). Clamp into sample_ = &sample;
// [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 — const ZonePlayParams& p = sample.play;
// the map clamps) is likewise pinned to 0. 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<std::int64_t>(sample.frames.size()); const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame; std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0; if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start); readPos_ = static_cast<double>(start);
sample_ = &sample; startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
env_.configure(adsr);
// --- 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(); 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<std::int64_t>(
static_cast<double>(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() { void Voice::release() {
if (!active_) return; 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; releasing_ = true;
env_.noteOff(); 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<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
}
return amp;
}
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, // 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 // 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 // every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
// scales all channels equally (a voice is one envelope). The head advances by exactly one // per frame and scale all channels equally (a voice is one envelope). The head advances by
// ratio step per call, so mono and stereo consume the sample at the same rate. // exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
if (!active_ || sample_ == nullptr) { if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f; if (stereo) outR = 0.0f;
return 0.0f; return 0.0f;
@@ -188,30 +362,32 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
const bool haveR = stereo && sample_->channelCount() == 2; const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm; const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain: if a valid, non-zero-length loop exists and the read head // Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
// has advanced past the loop end, wrap it back into [start, end). A zero-length // valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
// loop (start == end) is treated as "no loop" — the note is allowed to run off // it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
// the sample end and go silent, rather than spinning on a zero span. // loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
const SampleLoop& loop = sample_->loop; const SampleLoop& loop = sample_->loop;
const bool loopUsable = loop.hasLoop && loop.end > loop.start && const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop &&
loop.start >= 0 && loop.end <= frameCount; loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount;
if (loopUsable) { if (loopUsable) {
const std::int64_t loopStart = loop.start; const double loopLen = static_cast<double>(loop.end - loop.start);
const std::int64_t loopEnd = loop.end; while (readPos_ >= static_cast<double>(loop.end)) {
const double loopLen = static_cast<double>(loopEnd - loopStart);
while (readPos_ >= static_cast<double>(loopEnd)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
} }
} }
// Ran off the end with no usable loop -> voice is done. // TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
if (readPos_ >= static_cast<double>(frameCount)) { // trigger envelope also finishes at the same frame count; either latches the voice idle.
const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
active_ = false; active_ = false;
if (stereo) outR = 0.0f; if (stereo) outR = 0.0f;
return 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. // second point wraps to loopStart so the seam is continuous.
const std::int64_t i0 = static_cast<std::int64_t>(readPos_); const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0); const double frac = readPos_ - static_cast<double>(i0);
@@ -219,29 +395,71 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
if (loopUsable && i1 >= loop.end) { if (loopUsable && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner. 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 i0ok = (i0 >= 0 && i0 < frameCount);
const bool i1ok = (i1 >= 0 && i1 < 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 gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
const double l0 = i0ok ? static_cast<double>(pcm[i0]) : 0.0; // Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read;
const double l1 = i1ok ? static_cast<double>(pcm[i1]) : 0.0; // Varispeed applies pitch by the read RATE, Preserve applies it by the shifter.
const double outL = (l0 + (l1 - l0) * frac) * gain; const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
double srcR = 0.0;
if (stereo) { if (stereo) {
const double r0 = i0ok ? static_cast<double>(pcmR[i0]) : 0.0; srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
const double r1 = i1ok ? static_cast<double>(pcmR[i1]) : 0.0; ((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
outR = static_cast<AudioSample>((r0 + (r1 - r0) * frac) * gain); (i0ok ? static_cast<double>(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<double>(shiftL_.process(static_cast<AudioSample>(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<double>(shiftR_.process(static_cast<AudioSample>(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<AudioSample>(outRlocal);
readPos_ += ratio_; readPos_ += ratio_;
if (env_.finished()) { if (amplitudeDone_) {
active_ = false; active_ = false;
} }
return static_cast<AudioSample>(outL); return static_cast<AudioSample>(outL);
@@ -262,10 +480,27 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
const AdsrParams& adsr) const AdsrParams& adsr, std::size_t preserveVoiceCap,
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr) { 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; // 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). // 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() { 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]; 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(); const std::size_t v = allocateVoice();
voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); voices_[v].start(note, velocity, sample, zone.rootNote, adsr_);
voices_[v].setStartOrder(nextStartOrder_++); voices_[v].setStartOrder(nextStartOrder_++);
+231 -23
View File
@@ -22,6 +22,7 @@
#include <vector> #include <vector>
#include "peaks.h" // AudioSample (float) #include "peaks.h" // AudioSample (float)
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
namespace reasampler { namespace reasampler {
@@ -33,6 +34,92 @@ namespace reasampler {
// itself never branches on it — the mode only picks which render overload the shell drives. // itself never branches on it — the mode only picks which render overload the shell drives.
enum class ChannelMode { Mono, Stereo }; 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 // 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 // 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. // a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
std::int64_t startFrame = 0; 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 // 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. // length than frames is treated as absent (mono) — a malformed pair never half-plays.
int channelCount() const { int channelCount() const {
@@ -134,30 +226,26 @@ struct Keymap {
double pitchRatio(int note, int rootNote); double pitchRatio(int note, int rootNote);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate: // AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic // (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
// four-stage shape, asserted against a known signal in the tests (mirror of peaks). // Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
// //
// Segment math (all linear ramps): // Segment math (all linear ramps):
// Attack: 0 -> 1 over attackFrames // Attack: 0 -> 1 over attackFrames
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
// Decay: 1 -> sustainLevel over decayFrames // Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff // Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames // Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to // A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
// sustain; a noteOff during attack/decay (release-before-sustain) releases from the // entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
// current partial level, not from sustainLevel. // 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 { class AdsrEnvelope {
public: 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; } void configure(const AdsrParams& params) { params_ = params; }
@@ -184,6 +272,64 @@ private:
double releaseFrom_ = 0.0; // level at the moment noteOff() was called 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 // A single voice: one active note playing one repitched, enveloped sample. Reads
// the sample by fractional frame position with linear interpolation, advancing by // the sample by fractional frame position with linear interpolation, advancing by
@@ -193,13 +339,21 @@ private:
class Voice { class Voice {
public: public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // 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), // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
// repitched from `rootNote`, with `adsr` as the amplitude envelope. // 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, 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, // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
// if it would) until the release finishes, then goes idle. // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
void release(); void release();
// True while this voice is producing (or about to produce) sound. // True while this voice is producing (or about to produce) sound.
@@ -211,6 +365,17 @@ public:
std::uint64_t startOrder() const { return startOrder_; } std::uint64_t startOrder() const { return startOrder_; }
void setStartOrder(std::uint64_t order) { startOrder_ = order; } void setStartOrder(std::uint64_t order) { startOrder_ = order; }
bool releasing() const { return releasing_; } 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 // 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 // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
@@ -229,19 +394,46 @@ public:
private: private:
// Shared read/advance for both render paths: computes the interpolated per-channel // 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 // 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. // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
AudioSample advanceFrame(bool stereo, AudioSample& outR); 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 active_ = false;
bool releasing_ = false; bool releasing_ = false;
int note_ = 0; int note_ = 0;
double velocityGain_ = 1.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 double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr; 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_; 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; std::uint64_t startOrder_ = 0;
}; };
@@ -262,8 +454,19 @@ class VoiceEngine {
public: public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from // Builds an engine with `maxVoices` voices (the polyphony bound) playing from
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it // `keymap`. The keymap must outlive the engine (the engine holds a reference — it
// reads zones and sample data through it, never copies PCM). // reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr); // 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 // 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 // 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). // one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice(); 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<Voice> voices_; std::vector<Voice> voices_;
const Keymap& keymap_; const Keymap& keymap_;
AdsrParams adsr_; 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" std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
}; };
+187
View File
@@ -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
// <windows.h> 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 <cmath>
#include <cstdio>
#include <vector>
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<AudioSample> sine(std::size_t frames, double cycles) {
std::vector<AudioSample> s(frames);
for (std::size_t i = 0; i < frames; ++i) {
s[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(frames)));
}
return s;
}
// Average spacing between positive-going zero crossings (the observed period).
static double observedPeriod(const std::vector<AudioSample>& out, std::size_t from) {
std::vector<std::size_t> 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<double>(up[i] - up[i - 1]);
return sum / static_cast<double>(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<AudioSample> 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<double>(n) / cycles; // 200
const std::vector<AudioSample> in = sine(n, cycles);
PitchShifter ps;
ps.configure(2205);
ps.warm();
ps.setShiftRatio(1.0);
std::vector<AudioSample> 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<double>(n) / cycles; // 200
const std::vector<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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;
}
+111
View File
@@ -979,6 +979,113 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
!back.map.zones[1].startPoint.has_value()); !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<std::uint8_t> 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<std::uint32_t>(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() { int main() {
testSelectByIdHit(); testSelectByIdHit();
testSelectEmptyIdIsSilence(); testSelectEmptyIdIsSilence();
@@ -1026,6 +1133,10 @@ int main() {
testPerformanceStateV1BackCompat(); testPerformanceStateV1BackCompat();
testPerformanceStateGarbage(); testPerformanceStateGarbage();
testPerformanceStateNegativeNotesRoundTrip(); testPerformanceStateNegativeNotesRoundTrip();
testPlayParamsRoundTrip();
testPlayParamsComposeWithLoopStart();
testPlayParamsV2BackCompatLiftsToDefaults();
testPlayParamsThroughComponentEnvelope();
testComponentStateRoundTrip(); testComponentStateRoundTrip();
testComponentStateLoopStartRoundTrip(); testComponentStateLoopStartRoundTrip();
testComponentStateSelectionOnlyNoZones(); testComponentStateSelectionOnlyNoZones();
+365
View File
@@ -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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<std::int64_t>(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<double>(atUp), static_cast<double>(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<AudioSample> {
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<AudioSample> out;
eng.render(out, n);
return out;
};
const std::vector<AudioSample> a = renderOne(false);
const std::vector<AudioSample> 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<AudioSample> out;
eng.render(out, 4000);
// Early period (heavily transposed up) should be shorter than the late period (settled).
std::vector<AudioSample> early(out.begin(), out.begin() + 800);
std::vector<AudioSample> 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<float>(std::sin(2.0 * kPi * 8.0 *
static_cast<double>(i) / static_cast<double>(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<AudioSample> 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() { int main() {
testChromaticSingleRoot(); testChromaticSingleRoot();
testZonedRangesBoundaries(); testZonedRangesBoundaries();
@@ -863,6 +1211,23 @@ int main() {
testStereoRenderNullBufferIsNoOp(); testStereoRenderNullBufferIsNoOp();
testStereoStartFrameLoopShareOneReadHead(); 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) { if (g_fail == 0) {
std::printf("all sampler_core tests passed\n"); std::printf("all sampler_core tests passed\n");
return 0; return 0;