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:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user