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