S15/S16: Gate(AHDSR)/Trigger play modes + Varispeed/Preserve pitch engines + AD pitch envelope

Per-zone play params on SampleData; hand-rolled pure pitch_shift OLA for Preserve (WDL drags
windows.h); zone-payload v3 tail; RT-safe pre-warmed shifters + Preserve voice cap.
This commit is contained in:
2026-07-26 23:50:31 -04:00
parent 725f3e7d3c
commit 1e1d6bddbb
13 changed files with 1528 additions and 77 deletions
+126
View File
@@ -0,0 +1,126 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract and the S16-F2
// route-(b) rationale (WDL drags <windows.h>, so the Preserve DSP is house-native here).
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
//
// Algorithm: a single delay ring of `window_` frames. The write head advances one frame per
// input sample (source rate → duration preserved). TWO read taps chase the write head, offset
// by half a window; each advances by the shift `ratio_` per frame. A tap that would cross the
// write head wraps by a full window (so it stays a bounded delay behind the writer). The two
// taps are crossfaded by an equal-power window keyed to each tap's distance from the write
// head, so the wrap discontinuity of one tap is masked by the other mid-window — the classic
// two-grain time-domain pitch shifter, no FFT.
#include "pitch_shift.h"
#include <algorithm>
#include <cmath>
namespace reasampler {
namespace {
// A Hann OLA window over a grain phase in [0,1): 0.5(1 - cos(2*pi*phase)). Zero at the grain
// ends (where a tap wraps — the discontinuity), unity mid-grain. Two grains offset by half a
// window PARTITION UNITY (w(p) + w(p+0.5) == 1 for all p), so the two crossfaded taps sum to a
// gain of exactly 1 everywhere — no amplitude ripple across the window, and each tap's wrap
// seam is masked because its window is 0 exactly there.
double hannWeight(double phase) {
while (phase < 0.0) phase += 1.0;
while (phase >= 1.0) phase -= 1.0;
return 0.5 * (1.0 - std::cos(2.0 * 3.14159265358979323846 * phase));
}
} // namespace
void PitchShifter::configure(std::int64_t windowFrames) {
window_ = windowFrames;
if (window_ <= 1) {
// Pass-through: no ring, process() returns input unchanged.
ring_.clear();
writePos_ = 0;
readPos_ = 0.0;
ratio_ = 1.0;
return;
}
ring_.assign(static_cast<std::size_t>(window_), 0.0f);
reset();
}
void PitchShifter::reset() {
if (window_ > 1) {
// Zero the ring and seed the read head a half-window behind the writer so the two taps
// (readPos_ and readPos_ + window/2) straddle the writer from the first frame.
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0;
readPos_ = static_cast<double>(window_) / 2.0;
} else {
writePos_ = 0;
readPos_ = 0.0;
}
ratio_ = 1.0;
}
void PitchShifter::warm() {
if (window_ <= 1) return; // pass-through needs no warm-up
// Push one full window of silence so the taps reach steady state before real audio.
for (std::int64_t i = 0; i < window_; ++i) process(0.0f);
}
void PitchShifter::setShiftRatio(double ratio) {
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run taps backward/stall)
}
AudioSample PitchShifter::process(AudioSample in) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// 1. Write the incoming sample at the write head (source rate).
ring_[static_cast<std::size_t>(writePos_)] = in;
const double w = static_cast<double>(window_);
const double half = w / 2.0;
// 2. Read the two taps, each a bounded delay behind the writer. tap0 is `readPos_`; tap1 is
// a half-window ahead of it (mod window). Distance-from-writer drives the crossfade so a
// tap near the writer (about to wrap) is faded out while its partner (mid-window) is up.
auto readTap = [&](double pos) -> double {
// Fractional linear interpolation with ring wrap.
double p = pos;
while (p < 0.0) p += w;
while (p >= w) p -= w;
const std::int64_t i0 = static_cast<std::int64_t>(p);
const double frac = p - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (i1 >= window_) i1 = 0;
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(i1)]);
return s0 + (s1 - s0) * frac;
};
const double tap0 = readTap(readPos_);
const double tap1 = readTap(readPos_ + half);
// Distance of tap0 behind the write head, in [0, window). Its crossfade phase is that
// distance over the window; tap1 (half a window offset) gets the complementary phase.
double dist0 = static_cast<double>(writePos_) - readPos_;
while (dist0 < 0.0) dist0 += w;
while (dist0 >= w) dist0 -= w;
const double phase0 = dist0 / w;
// Hann windows offset by half a grain partition unity, so the two taps sum to gain 1 with
// each tap's wrap seam masked by its window zero. phase0 drives tap0; tap1 (half-window
// offset) is at phase0 + 0.5.
const double g0 = hannWeight(phase0);
const double g1 = hannWeight(phase0 + 0.5);
const double out = tap0 * g0 + tap1 * g1;
// 3. Advance heads: write head one frame (source rate), read head by the shift ratio.
++writePos_;
if (writePos_ >= window_) writePos_ = 0;
readPos_ += ratio_;
while (readPos_ >= w) readPos_ -= w;
while (readPos_ < 0.0) readPos_ += w;
return static_cast<AudioSample>(out);
}
} // namespace reasampler
+88
View File
@@ -0,0 +1,88 @@
#pragma once
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps
// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1
// (duration held); only the PITCH changes — an octave up plays the same wall-clock length
// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
//
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
// route (a) `WDL_SimplePitchShifter`. But its include chain
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
// the SHELL, never in the pure core.
//
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_trim do the same).
//
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state
// latency is reached before the first real sample (no cold-start click). `process()` does
// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain
// value fields, so a voice owning one by value costs a fixed ring buffer per channel.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "peaks.h" // AudioSample (float)
namespace reasampler {
// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo
// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count
// agnostic, matching the S7 "one read head, per-channel value" idiom of the core.
//
// The default-constructed shifter is INERT: with no configure() it passes input through
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
// byte-identical to the pre-S16 engine.
class PitchShifter {
public:
// Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two
// read taps a half-window apart. `windowFrames` <= 1 degrades to pass-through (no ring),
// so a degenerate configure never divides by zero or wraps a zero span. Called OFF the
// audio thread (allocates). Resets all running state. A larger window = smoother on large
// transpositions but more latency; the shell picks it from the Preserve quality setting.
void configure(std::int64_t windowFrames);
// Pre-fill the ring with silence (one full window of zero writes) so the read taps reach
// steady state before the first real sample. Removes the cold-start seam (the S16 "onset
// click absent" requirement) — call once at voice allocation after configure(). No-op when
// unconfigured (pass-through needs no warm-up).
void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
// 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap
// advance simply uses the current value. Values <= 0 are ignored (kept at the last valid
// ratio) so a bad input never runs the taps backward or stalls them.
void setShiftRatio(double ratio);
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
// head, reads the two half-window-offset taps advancing at the shift ratio, crossfades
// them by the write-head-relative distance (equal-power), and advances both heads by one.
AudioSample process(AudioSample in);
// Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded)
// WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window.
void reset();
// True once configure() sized a real ring (window > 1). A pass-through shifter is false.
bool configured() const { return window_ > 1; }
std::int64_t window() const { return window_; }
private:
std::vector<AudioSample> ring_; // delay line, length `window_` (channel-local)
std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through
std::int64_t writePos_ = 0; // integer write head into the ring (source rate)
double readPos_ = 0.0; // fractional read head (advances at shift ratio)
double ratio_ = 1.0; // current shift ratio (>0)
};
} // namespace reasampler
+15 -1
View File
@@ -41,6 +41,13 @@ constexpr double kSustainLevel = 1.0;
constexpr double kReleaseSeconds = 0.060;
constexpr std::size_t kMaxVoices = 16;
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of
// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
constexpr std::size_t kPreserveVoiceCap = 8;
AdsrParams tier0Adsr(double sampleRate) {
const double sr = sampleRate > 0.0 ? sampleRate : 44100.0;
AdsrParams p;
@@ -363,8 +370,15 @@ std::string ReaSamplerProcessor::reloadFromBank() {
}
if (haveKeymap) {
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
// note-on never allocates. Floored at 2 so a valid window is always a real ring.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen);
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap,
preserveWindow);
}
}
+5 -2
View File
@@ -51,8 +51,11 @@ struct LoadedInstrument {
std::uint64_t installedAt = 0; // reload generation at which this was installed
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr,
std::uint64_t gen)
: keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {}
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0)
: keymap(std::move(km)),
engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames),
installedAt(gen) {}
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
+51 -2
View File
@@ -135,7 +135,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR) {
std::vector<AudioSample> framesR, const ZonePlayParams& play) {
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
@@ -146,6 +146,7 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
data.rootNote = rootNote;
data.loop = loop;
data.play = play; // S15/S16 single-capture play params (product defaults unless overridden)
return Keymap::singleSampleChromatic(std::move(data));
}
@@ -186,6 +187,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
// never mutated — this only shapes what the core plays for THIS instance (D-B).
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params carry through unchanged (they are instrument state, not
// resolved against the bank) so the keymap build can stamp them onto the SampleData.
rz.play = z.play;
out.zones.push_back(std::move(rz));
}
return out;
@@ -210,6 +214,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes
const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data));
KeyZone zone;
@@ -241,6 +246,19 @@ void putU64le(std::vector<std::uint8_t>& out, 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
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
// blob degrades to a partial/empty parse rather than reading out of bounds.
@@ -324,6 +342,20 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
}
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
// S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone
// has a play mode + engine — no flag gate). Order matches the header's v3 record spec.
const ZonePlayParams& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, asU64(pp.adsr.holdFrames));
putU64le(out, doubleToBits(pp.trigger.lengthFraction));
putU64le(out, asU64(pp.trigger.fadeInFrames));
putU64le(out, asU64(pp.trigger.fadeOutFrames));
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putU64le(out, asU64(pp.pitchEnv.attackFrames));
putU64le(out, asU64(pp.pitchEnv.decayFrames));
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones));
}
}
@@ -333,14 +365,18 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest.
void readZonesPayload(ByteReader& r, PerformanceMap& map) {
bool extended = false;
bool extended = false; // v2+: the S11 loop/start tail is present
bool hasPlay = false; // v3+: the S15/S16 play-params tail is present
if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker
const std::uint32_t pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail
hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail
}
const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play
// tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change.
PerformanceZone z;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
@@ -360,6 +396,19 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64();
}
if (hasPlay) {
// S15/S16 play params, always present in a v3 record (read in the emit order).
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdFrames = r.i64();
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackFrames = r.i64();
z.play.pitchEnv.decayFrames = r.i64();
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
}
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z));
}
+43 -7
View File
@@ -114,9 +114,16 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
// pair never half-plays. `sampleRate` is the WAV's rate.
// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to
// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under
// the same default engine as a zone would. The editor will surface per-capture overrides later
// (S15-F2 one-zone-map lean); until then this is the one place the single-capture default lives.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
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) ---------------
//
@@ -147,6 +154,17 @@ struct PerformanceZone {
std::optional<int> rootOverride; // instrument-owned override; 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
// S15/S16 per-zone play parameters (play mode + AHDSR hold + Trigger %-length/fades; pitch
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
// loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode,
// hold 0, no fades, and the PRESERVE pitch engine (S16-F1 — Daniel's directive; the one
// flippable default is sampler_core::kDefaultPitchEngine), pitch envelope off. An older
// zone-payload blob (no S15/S16 tail) lifts to exactly these defaults on read (see the
// PAYLOAD v3 versioning in the (de)serialize section), so a pre-S15 instrument opens with
// Gate + Preserve — the deliberate, spec-flagged behavior change.
ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine,
PitchEnvParams{}};
};
// The instrument's performance map: an ordered list of zones. Order is authoritative for
@@ -170,6 +188,8 @@ struct ResolvedZone {
int rootNote = 60; // effective: override, else bank intrinsic, else 60
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine,
PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is)
};
// The result of resolving a performance map against the live bank blob. `zones` are the
@@ -249,6 +269,20 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS,
// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play
// params — always present, NOT flag-gated, since every zone has a play mode + engine):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage (A/D/S/R timing stays
// instrument-wide; only hold is per-zone);
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64);
// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double.
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
@@ -261,12 +295,14 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11). serializePerformance and
// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the
// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload
// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count
// (bounded by 128 MIDI zones in practice, always tiny) can never collide with.
inline constexpr std::uint32_t kZonesPayloadVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance
// and serializeComponentState both emit the CURRENT payload version (v3 — marker + version +
// records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload
// (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults.
// The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in
// practice, always tiny) can never collide with.
inline constexpr std::uint32_t kZonesPayloadVersion = 3; // S15/S16: per-zone play params tail
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// The performance map serialized to bytes for IBStream (getState).
+287 -40
View File
@@ -85,6 +85,32 @@ double AdsrEnvelope::tick() {
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.attackFrames) {
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
stage_ = Stage::Hold;
framesInStage_ = 0;
level_ = 1.0;
}
return out;
}
case Stage::Hold: {
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
if (params_.holdFrames <= 0) {
stage_ = Stage::Decay;
framesInStage_ = 0;
// Fall through to Decay this frame so no extra unity sample is emitted for a
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
level_ = 1.0;
// Re-dispatch by recursion-free goto-equivalent: evaluate Decay immediately.
return tick();
}
level_ = 1.0;
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.holdFrames) {
stage_ = Stage::Decay;
framesInStage_ = 0;
level_ = 1.0;
@@ -136,46 +162,194 @@ double AdsrEnvelope::tick() {
return 0.0; // unreachable; silences a warning.
}
// ---------------------------------------------------------------------------
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
// ---------------------------------------------------------------------------
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve) {
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
curve_ = curve;
finished_ = (playLength_ <= 0);
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
// negative fade is treated as 0. When both fades together exceed the play length, shrink
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
// then the fade-in — never letting either go negative or the sum exceed the span.
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
if (fi > playLength_) fi = playLength_;
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
fadeIn_ = fi;
fadeOut_ = fo;
}
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
if (finished_ || sourceOffset < 0.0 ||
sourceOffset >= static_cast<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
// ---------------------------------------------------------------------------
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_.
shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames);
}
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
const AdsrParams& adsr) {
const AdsrParams& gateAdsr) {
active_ = true;
releasing_ = false;
amplitudeDone_ = false;
note_ = note;
// MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively.
int v = velocity;
if (v < 0) v = 0;
if (v > 127) v = 127;
velocityGain_ = static_cast<double>(v) / 127.0;
ratio_ = pitchRatio(note, rootNote);
// Initial read position honors the sample's start-point offset (S11). Clamp into
// [0, frames): a start at or past the end degrades to 0 (play from the top) rather
// than starting a voice already off the end. A negative start (shouldn't occur —
// the map clamps) is likewise pinned to 0.
baseRatio_ = pitchRatio(note, rootNote);
sample_ = &sample;
const ZonePlayParams& p = sample.play;
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
sample_ = &sample;
env_.configure(adsr);
env_.noteOn();
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
// --- Amplitude envelope: Gate = AHDSR (instrument A/D/S/R + per-zone HOLD); Trigger = the
// time-boxed fade-in/out over the % play length. ---
if (playMode_ == PlayMode::Gate) {
AdsrParams a = gateAdsr;
a.holdFrames = p.adsr.holdFrames; // per-zone hold folds into the instrument-wide AHDSR
env_.configure(a);
env_.noteOn();
playEnd_ = 0; // unused in Gate
} else {
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
double frac = p.trigger.lengthFraction;
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
if (frac > 1.0) frac = 1.0;
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
std::int64_t playLen = static_cast<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() {
if (!active_) return;
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
if (playMode_ == PlayMode::Trigger) return;
releasing_ = true;
env_.noteOff();
}
double Voice::tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
// land on the same source frames under either engine's read rate. The voice ALSO frees on
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
}
return amp;
}
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
// every channel — only the PCM value read differs. The envelope ticks ONCE per frame and
// scales all channels equally (a voice is one envelope). The head advances by exactly one
// ratio step per call, so mono and stereo consume the sample at the same rate.
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
// per frame and scale all channels equally (a voice is one envelope). The head advances by
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f;
return 0.0f;
@@ -188,30 +362,32 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain: if a valid, non-zero-length loop exists and the read head
// has advanced past the loop end, wrap it back into [start, end). A zero-length
// loop (start == end) is treated as "no loop" — the note is allowed to run off
// the sample end and go silent, rather than spinning on a zero span.
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = loop.hasLoop && loop.end > loop.start &&
loop.start >= 0 && loop.end <= frameCount;
const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop &&
loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount;
if (loopUsable) {
const std::int64_t loopStart = loop.start;
const std::int64_t loopEnd = loop.end;
const double loopLen = static_cast<double>(loopEnd - loopStart);
while (readPos_ >= static_cast<double>(loopEnd)) {
const double loopLen = static_cast<double>(loop.end - loop.start);
while (readPos_ >= static_cast<double>(loop.end)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
}
}
// Ran off the end with no usable loop -> voice is done.
if (readPos_ >= static_cast<double>(frameCount)) {
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
// trigger envelope also finishes at the same frame count; either latches the voice idle.
const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
active_ = false;
if (stereo) outR = 0.0f;
return 0.0f;
}
// Linear interpolation between the two bracketing frames. For the loop case, the
// Linear interpolation between the two bracketing SOURCE frames. For the loop case, the
// second point wraps to loopStart so the seam is continuous.
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
@@ -219,29 +395,71 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
if (loopUsable && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
// i0 is always in [0, frameCount) after the early-out above; the guard is purely
// defensive. i1 (the interpolation partner) can exceed frameCount when no loop
// wraps it — only that partner actually needs the clamp. framesR is length-matched
// to frames (channelCount() enforces it), so the same indices are valid in both.
const bool i0ok = (i0 >= 0 && i0 < frameCount);
const bool i1ok = (i1 >= 0 && i1 < frameCount);
const double amp = env_.tick();
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
const double amp = tickAmplitude();
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
const double l0 = i0ok ? static_cast<double>(pcm[i0]) : 0.0;
const double l1 = i1ok ? static_cast<double>(pcm[i1]) : 0.0;
const double outL = (l0 + (l1 - l0) * frac) * gain;
// Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read;
// Varispeed applies pitch by the read RATE, Preserve applies it by the shifter.
const double srcL = (i0ok ? static_cast<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) {
const double r0 = i0ok ? static_cast<double>(pcmR[i0]) : 0.0;
const double r1 = i1ok ? static_cast<double>(pcmR[i1]) : 0.0;
outR = static_cast<AudioSample>((r0 + (r1 - r0) * frac) * gain);
srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(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_;
if (env_.finished()) {
if (amplitudeDone_) {
active_ = false;
}
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,
const AdsrParams& adsr)
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr) {
const AdsrParams& adsr, std::size_t preserveVoiceCap,
std::int64_t preserveWindowFrames)
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr),
preserveVoiceCap_(preserveVoiceCap) {
// maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
// clamp to a single voice so the engine is always usable (documented degenerate).
//
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
// allocation point for the shifter rings across the engine's lifetime.
if (preserveWindowFrames > 1) {
for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames);
}
}
std::size_t VoiceEngine::activePreserveVoices() const {
std::size_t n = 0;
for (const Voice& v : voices_) {
if (v.active() && v.pitchEngine() == PitchEngine::Preserve) ++n;
}
return n;
}
std::size_t VoiceEngine::allocateVoice() {
@@ -305,6 +540,18 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
}
const SampleData& sample = keymap_.samples[zone.sampleIndex];
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
activePreserveVoices() >= preserveVoiceCap_) {
return kNoVoice;
}
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
const std::size_t v = allocateVoice();
voices_[v].start(note, velocity, sample, zone.rootNote, adsr_);
voices_[v].setStartOrder(nextStartOrder_++);
+232 -24
View File
@@ -21,7 +21,8 @@
#include <cstdint>
#include <vector>
#include "peaks.h" // AudioSample (float)
#include "peaks.h" // AudioSample (float)
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
namespace reasampler {
@@ -33,6 +34,92 @@ namespace reasampler {
// itself never branches on it — the mode only picks which render overload the shell drives.
enum class ChannelMode { Mono, Stereo };
// ---------------------------------------------------------------------------
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
// with the rest of the engine machinery; only the value structs need to precede SampleData.
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
struct AdsrParams {
std::int64_t attackFrames = 0;
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0;
};
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
enum class PlayMode { Gate, Trigger };
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
};
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
// so a third curve can join without a signature change.
enum class FadeCurve { EqualPower, Linear };
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve };
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
// engine" holds for the core's own regression tests (an octave up still halves duration in the
// bare engine); the Preserve product default is layered on above at (de)serialization.
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation.
inline constexpr double kPreserveWindowMs = 50.0;
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
struct PitchEnvParams {
bool enabled = false;
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double peakSemitones = 0.0; // signed depth at the peak
};
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
struct ZonePlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate: the AHDSR envelope
TriggerParams trigger; // Trigger: %-length + fades
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
};
// ---------------------------------------------------------------------------
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
// govern playback. The shell decodes the on-disk WAV and fills this; the core
@@ -69,6 +156,11 @@ struct SampleData {
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
std::int64_t startFrame = 0;
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
ZonePlayParams play;
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
int channelCount() const {
@@ -134,30 +226,26 @@ struct Keymap {
double pitchRatio(int note, int rootNote);
// ---------------------------------------------------------------------------
// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate:
// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic
// four-stage shape, asserted against a known signal in the tests (mirror of peaks).
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
//
// Segment math (all linear ramps):
// Attack: 0 -> 1 over attackFrames
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
// Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to
// sustain; a noteOff during attack/decay (release-before-sustain) releases from the
// current partial level, not from sustainLevel.
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
// ---------------------------------------------------------------------------
struct AdsrParams {
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0;
};
class AdsrEnvelope {
public:
enum class Stage { Idle, Attack, Decay, Sustain, Release, Finished };
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
void configure(const AdsrParams& params) { params_ = params; }
@@ -184,6 +272,64 @@ private:
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
};
// ---------------------------------------------------------------------------
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
// FadeCurve value structs are defined above with the other per-zone params.
// ---------------------------------------------------------------------------
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
// output and source frames coincide, but under Varispeed a transposed voice consumes source
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
class TriggerEnvelope {
public:
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
// the offset (no internal advance) so it composes with either pitch engine's read rate.
double amplitudeAt(double sourceOffset);
bool finished() const { return finished_; }
private:
std::int64_t playLength_ = 0;
std::int64_t fadeIn_ = 0;
std::int64_t fadeOut_ = 0;
FadeCurve curve_ = kDefaultFadeCurve;
bool finished_ = false;
};
// ---------------------------------------------------------------------------
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
// ---------------------------------------------------------------------------
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
class PitchEnvelope {
public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; }
// Advance one frame, return this frame's pitch offset in semitones.
double tick();
private:
PitchEnvParams params_;
std::int64_t pos_ = 0;
};
// ---------------------------------------------------------------------------
// A single voice: one active note playing one repitched, enveloped sample. Reads
// the sample by fractional frame position with linear interpolation, advancing by
@@ -193,13 +339,21 @@ private:
class Voice {
public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
// the caller must keep alive for the voice's lifetime — the Keymap owns it),
// repitched from `rootNote`, with `adsr` as the amplitude envelope.
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
// from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the
// instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from
// sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the
// S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST
// already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s
// them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm
// silence pass settles the OLA taps before the first output frame (no cold-start click).
// Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no
// pitch env).
void start(int note, int velocity, const SampleData& sample, int rootNote,
const AdsrParams& adsr);
const AdsrParams& gateAdsr);
// Gate off — begins the amplitude release. The voice keeps rendering (and looping,
// if it would) until the release finishes, then goes idle.
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
void release();
// True while this voice is producing (or about to produce) sound.
@@ -211,6 +365,17 @@ public:
std::uint64_t startOrder() const { return startOrder_; }
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
bool releasing() const { return releasing_; }
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
// meaningful while active().
PitchEngine pitchEngine() const { return pitchEngine_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
// audio thread (this allocates). The engine calls it once at construction so start() — which
// runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s
// the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op
// in the underlying vector.
void presizePreserveShifters(std::int64_t windowFrames);
// Renders one frame's contribution, advancing the read head and envelope by one
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
@@ -229,19 +394,46 @@ public:
private:
// Shared read/advance for both render paths: computes the interpolated per-channel
// value(s) at the current read head, ticks the envelope once, advances the head, and
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
AudioSample advanceFrame(bool stereo, AudioSample& outR);
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
double tickAmplitude();
bool active_ = false;
bool releasing_ = false;
int note_ = 0;
double velocityGain_ = 1.0;
double ratio_ = 1.0; // fractional frames advanced per output frame
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_;
TriggerEnvelope trigEnv_;
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
PitchShifter shiftR_;
std::uint64_t startOrder_ = 0;
};
@@ -262,8 +454,19 @@ class VoiceEngine {
public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
// reads zones and sample data through it, never copies PCM).
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr);
// reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide
// Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode +
// pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many
// Preserve-engine voices may sound at once (the shifter is materially heavier than
// Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means
// "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA
// window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at
// construction (OFF the audio thread), so note-on (which runs in process()) never allocates;
// 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor
// derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers
// (and the pure-core tests) are unaffected.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
@@ -313,9 +516,14 @@ private:
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice();
// Count of active Preserve-engine voices (for the S16 Preserve cap). A cheap running tally
// kept in sync at note-on/steal/free rather than rescanned per note.
std::size_t activePreserveVoices() const;
std::vector<Voice> voices_;
const Keymap& keymap_;
AdsrParams adsr_;
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
};