S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate
Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped. Editor sliders now seconds. Engine takes frames resolved at keymap build.
This commit is contained in:
@@ -298,19 +298,19 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
|
||||
namespace {
|
||||
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
|
||||
// engine-free and maps only 0..1). Time sliders span [0, max] frames at the NOMINAL 44100 Hz
|
||||
// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider
|
||||
// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only
|
||||
// because the host rate is not reachable inside the editor without a processor callback, and the
|
||||
// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one
|
||||
// place to retune; not persisted.
|
||||
constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal)
|
||||
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
|
||||
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
|
||||
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
|
||||
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
|
||||
// wall-clock second). Build-time residual — one place to retune; not persisted.
|
||||
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
|
||||
constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
|
||||
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
} // namespace
|
||||
|
||||
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const {
|
||||
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
|
||||
std::vector<ControlDesc> out;
|
||||
// Always: the two mode toggles.
|
||||
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
|
||||
@@ -335,24 +335,27 @@ std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& pl
|
||||
return out;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const {
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
|
||||
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
|
||||
const auto framesToNorm = [](std::int64_t f) {
|
||||
return clamp01(static_cast<double>(f) / kEnvTimeMaxFrames);
|
||||
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
|
||||
};
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
||||
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||
case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames);
|
||||
case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames);
|
||||
case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames);
|
||||
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
|
||||
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
|
||||
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
|
||||
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
|
||||
case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames);
|
||||
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
|
||||
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
||||
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
|
||||
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
|
||||
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
|
||||
case ParamControl::kPitchEnvAttack:return framesToNorm(play.pitchEnv.attackFrames);
|
||||
case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames);
|
||||
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
|
||||
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||
@@ -360,10 +363,11 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
int segment) const {
|
||||
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||
const auto normToFrames = [](double v) {
|
||||
return static_cast<std::int64_t>(clamp01(v) * kEnvTimeMaxFrames + 0.5);
|
||||
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
|
||||
};
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
@@ -372,11 +376,11 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
|
||||
case ParamControl::kPitchEngine:
|
||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
break;
|
||||
case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break;
|
||||
case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break;
|
||||
case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break;
|
||||
case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
|
||||
case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
|
||||
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
||||
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||
case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(value); break;
|
||||
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
|
||||
case ParamControl::kTrigLength:
|
||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||
@@ -386,8 +390,8 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
play.pitchEnv.enabled = (segment == 1);
|
||||
break;
|
||||
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break;
|
||||
case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break;
|
||||
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
|
||||
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||
break;
|
||||
@@ -999,7 +1003,7 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) {
|
||||
// PerformanceZone product defaults when the map is empty but a capture is picked
|
||||
// (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map;
|
||||
// see paintZones for the gate that reaches here).
|
||||
ZonePlayParams play;
|
||||
ZonePlaySeconds play;
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
} else if (map_.zones.empty() && !selectedId_.empty()) {
|
||||
@@ -1284,7 +1288,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
// Synthesize a probe layout with the product defaults to see if the click is in the
|
||||
// panel before committing to creating the zone.
|
||||
const Rect panel = zonesControlPanel(bands);
|
||||
const ZonePlayParams defaultPlay = PerformanceZone{}.play;
|
||||
const ZonePlaySeconds defaultPlay = PerformanceZone{}.play;
|
||||
const std::vector<ControlDesc> probeDescs = controlDescs(defaultPlay);
|
||||
const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs);
|
||||
if (controlAtPoint(probeRows, x, y) >= 0) {
|
||||
@@ -1321,14 +1325,6 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
dragParamPanel_ = panel;
|
||||
dragStartMap_ = map_;
|
||||
applyControl(id, z.play, valueAtPoint(r.control, x), 0);
|
||||
// An explicit ADSR slider touch commits a rate-resolved value (the slider
|
||||
// maps 0..1 -> editor-domain frames at kEnvTimeMaxFrames, not nominal 44100-Hz
|
||||
// counts). Mark the zone as no longer needing rate-resolve so buildZonedKeymap
|
||||
// does not re-rescale the value at reload time.
|
||||
if (id >= static_cast<int>(ParamControl::kAttack) &&
|
||||
id <= static_cast<int>(ParamControl::kRelease)) {
|
||||
z.adsrNeedsRateResolve = false;
|
||||
}
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -178,22 +178,24 @@ private:
|
||||
|
||||
// --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------
|
||||
//
|
||||
// The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16
|
||||
// pitch engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact.
|
||||
// The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16
|
||||
// pitch engine + AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap
|
||||
// build resolves them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
|
||||
|
||||
// The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles +
|
||||
// the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope
|
||||
// controls. The pure param_slider lays these out; this only picks the set. Static (a free
|
||||
// choice of set from the mode) — kept a member for the ParamControl enum access.
|
||||
std::vector<ControlDesc> controlDescs(const ZonePlayParams& play) const;
|
||||
std::vector<ControlDesc> controlDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
|
||||
// mapping: frames->0..1 over a fixed max, sustain 0..1 as-is, semitone depth centered at 0.5).
|
||||
double controlValue(int id, const ZonePlayParams& play) const;
|
||||
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
|
||||
// frames->0..1, semitone depth centered at 0.5).
|
||||
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||
|
||||
// Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back
|
||||
// into the control's engine domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlayParams& play, double value, int segment) const;
|
||||
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
|
||||
|
||||
@@ -37,10 +37,6 @@ namespace {
|
||||
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
|
||||
// dynamics), a short release for a natural tail. Times are in seconds, converted to
|
||||
// frames against the live sample rate at build time.
|
||||
constexpr double kAttackSeconds = 0.003;
|
||||
constexpr double kDecaySeconds = 0.0;
|
||||
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
|
||||
@@ -50,16 +46,6 @@ constexpr std::size_t kMaxVoices = 16;
|
||||
// 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;
|
||||
p.attackFrames = static_cast<std::int64_t>(kAttackSeconds * sr);
|
||||
p.decayFrames = static_cast<std::int64_t>(kDecaySeconds * sr);
|
||||
p.sustainLevel = kSustainLevel;
|
||||
p.releaseFrames = static_cast<std::int64_t>(kReleaseSeconds * sr);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
@@ -384,12 +370,13 @@ 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.
|
||||
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
||||
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5);
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap,
|
||||
std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
|
||||
preserveWindow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ struct LoadedInstrument {
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
||||
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr,
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0)
|
||||
: keymap(std::move(km)),
|
||||
engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames),
|
||||
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
|
||||
installedAt(gen) {}
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
|
||||
+83
-84
@@ -133,9 +133,35 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
|
||||
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
|
||||
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 44100.0;
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
double f = sec * sr;
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
|
||||
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
|
||||
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
|
||||
out.trigger = stored.trigger; // source-frame / fraction, unchanged
|
||||
out.pitchEngine = stored.pitchEngine;
|
||||
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlayParams& play) {
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
@@ -146,23 +172,8 @@ 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)
|
||||
|
||||
// The default `play` arg carries 44100-Hz nominal ADSR frame counts (kTier0Nominal*).
|
||||
// Rescale A/D/R by (sampleRate / 44100) so the wall-clock ADSR matches tier0Adsr(sampleRate)
|
||||
// exactly. Sustain (a level, not a frame count) is unchanged. At 44100 the factor is 1.0 —
|
||||
// bit-identical to the pre-fix build. The tier-0 single-capture path never carries user-edited
|
||||
// ADSR (users edit ADSR through zones, which go through buildZonedKeymap), so always rescaling
|
||||
// here is correct and safe.
|
||||
if (data.sampleRate != 44100) {
|
||||
const double factor = static_cast<double>(data.sampleRate) / 44100.0;
|
||||
data.play.adsr.attackFrames = static_cast<std::int64_t>(
|
||||
static_cast<double>(data.play.adsr.attackFrames) * factor + 0.5);
|
||||
data.play.adsr.decayFrames = static_cast<std::int64_t>(
|
||||
static_cast<double>(data.play.adsr.decayFrames) * factor + 0.5);
|
||||
data.play.adsr.releaseFrames = static_cast<std::int64_t>(
|
||||
static_cast<double>(data.play.adsr.releaseFrames) * factor + 0.5);
|
||||
}
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
}
|
||||
@@ -204,11 +215,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.
|
||||
// S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
|
||||
// state, not resolved against the bank); buildZonedKeymap resolves them to frames.
|
||||
rz.play = z.play;
|
||||
// Carry the rate-resolve flag so buildZonedKeymap can rescale 44100-nominal ADSR counts.
|
||||
rz.adsrNeedsRateResolve = z.adsrNeedsRateResolve;
|
||||
out.zones.push_back(std::move(rz));
|
||||
}
|
||||
return out;
|
||||
@@ -233,21 +242,9 @@ 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
|
||||
|
||||
// If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve),
|
||||
// its A/D/R frame counts are 44100-Hz nominals. Rescale to the WAV's actual rate so
|
||||
// wall-clock ADSR durations match tier0Adsr(sampleRate) exactly. v4 zones (user-edited
|
||||
// frame counts) carry adsrNeedsRateResolve=false and are left unchanged.
|
||||
if (zones[i].adsrNeedsRateResolve && data.sampleRate != 44100) {
|
||||
const double factor = static_cast<double>(data.sampleRate) / 44100.0;
|
||||
data.play.adsr.attackFrames = static_cast<std::int64_t>(
|
||||
static_cast<double>(data.play.adsr.attackFrames) * factor + 0.5);
|
||||
data.play.adsr.decayFrames = static_cast<std::int64_t>(
|
||||
static_cast<double>(data.play.adsr.decayFrames) * factor + 0.5);
|
||||
data.play.adsr.releaseFrames = static_cast<std::int64_t>(
|
||||
static_cast<double>(data.play.adsr.releaseFrames) * factor + 0.5);
|
||||
}
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
@@ -376,26 +373,25 @@ 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;
|
||||
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
|
||||
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
|
||||
// fraction. Order matches the header's v5 record spec.
|
||||
const ZonePlaySeconds& 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));
|
||||
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
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));
|
||||
// S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4).
|
||||
// Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults
|
||||
// at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build.
|
||||
putU64le(out, asU64(pp.adsr.attackFrames));
|
||||
putU64le(out, asU64(pp.adsr.decayFrames));
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putU64le(out, asU64(pp.adsr.releaseFrames));
|
||||
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,20 +401,19 @@ 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; // v2+: the S11 loop/start tail is present
|
||||
bool hasPlay = false; // v3+: the S15/S16 play-params tail is present
|
||||
bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present
|
||||
bool extended = false; // v2+: the S11 loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
const std::uint32_t pv = r.u32(); // payload version
|
||||
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
|
||||
hasAdsr = (pv >= 4); // v4+ carries the full A/D/S/R tail
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds
|
||||
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.
|
||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
|
||||
PerformanceZone z;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
@@ -438,37 +433,41 @@ 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).
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
|
||||
// were written as 44.1k-nominal frames -> divide by kLegacyV3NominalRate to reach the
|
||||
// seconds domain. Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are
|
||||
// ABSENT in v3 -> leave the seconds defaults on z.play.adsr (0.003 / 0 / 1.0 / 0.060).
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdFrames = r.i64();
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
|
||||
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.attackSeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
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.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
if (hasAdsr) {
|
||||
// S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order.
|
||||
// These values were authored at the DAW's rate; mark them resolved (no rescaling).
|
||||
z.play.adsr.attackFrames = r.i64();
|
||||
z.play.adsr.decayFrames = r.i64();
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseFrames = r.i64();
|
||||
z.adsrNeedsRateResolve = false; // already rate-resolved; do NOT rescale at keymap build
|
||||
} else if (hasPlay) {
|
||||
// v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz)
|
||||
// so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat).
|
||||
// Voice::start now uses the zone's full ADSR; a zone with these values reproduces
|
||||
// the instrument-wide tier0Adsr behavior the pre-fix code applied unconditionally.
|
||||
z.play.adsr.attackFrames = kTier0NominalAttackFrames;
|
||||
z.play.adsr.decayFrames = kTier0NominalDecayFrames;
|
||||
z.play.adsr.sustainLevel = kTier0NominalSustainLevel;
|
||||
z.play.adsr.releaseFrames = kTier0NominalReleaseFrames;
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
|
||||
+95
-72
@@ -107,20 +107,50 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload
|
||||
// blob whose per-zone A/D/S/R fields are absent, and as the initializer for new zones / the
|
||||
// buildTier0Keymap default play arg. These are 44100-Hz nominal frame counts; buildTier0Keymap
|
||||
// and buildZonedKeymap both rescale the A/D/R frame counts by (sampleRate / 44100) at build
|
||||
// time when adsrNeedsRateResolve is set on the zone — so a v3-lifted or default zone plays with
|
||||
// the same wall-clock ADSR as tier0Adsr(liveRate) did before the fix. Bit-identical at 44100 Hz.
|
||||
// Zones loaded from a v4 blob (explicitly user-edited) carry adsrNeedsRateResolve=false and are
|
||||
// never rescaled — their stored frame counts already reflect the rate at which they were authored.
|
||||
// Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone
|
||||
// (member initializers) — both of which reference these values.
|
||||
inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded
|
||||
inline constexpr std::int64_t kTier0NominalDecayFrames = 0;
|
||||
inline constexpr double kTier0NominalSustainLevel = 1.0;
|
||||
inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 44100
|
||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||
//
|
||||
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
|
||||
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
|
||||
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
|
||||
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
|
||||
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
|
||||
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
|
||||
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
|
||||
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
double attackSeconds = 0.003; // tier-0 default
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060; // tier-0 default
|
||||
};
|
||||
|
||||
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
double attackSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
|
||||
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
|
||||
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
|
||||
struct ZonePlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
|
||||
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
|
||||
@@ -129,20 +159,14 @@ inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 441
|
||||
// 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 A/D/R frame counts in the default play arg are
|
||||
// 44100-Hz nominals; this function always rescales them by (sampleRate / 44100) before stamping
|
||||
// them on the SampleData so the ADSR wall-clock durations match tier0Adsr(sampleRate) exactly.
|
||||
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
|
||||
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
|
||||
// picked single capture plays under the same default engine as a zone would. This function
|
||||
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlayParams& play = ZonePlayParams{
|
||||
PlayMode::Gate,
|
||||
AdsrParams{kTier0NominalAttackFrames, 0,
|
||||
kTier0NominalDecayFrames, kTier0NominalSustainLevel,
|
||||
kTier0NominalReleaseFrames},
|
||||
TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}});
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
||||
//
|
||||
@@ -176,24 +200,12 @@ struct PerformanceZone {
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode + AHDSR + 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,
|
||||
// AHDSR with the tier-0 nominal A/D/S/R (kTier0Nominal* at 44100 Hz — the same values a
|
||||
// v3 blob lifts to), hold 0, no fades, and the PRESERVE pitch engine (S16-F1), pitch env
|
||||
// off. An older zone-payload blob (no S15/S16 tail or no v4 A/D/S/R tail) lifts to exactly
|
||||
// these defaults on read (see the PAYLOAD v3/v4 versioning), so a pre-v4 instrument opens
|
||||
// with Gate + Preserve + tier-0 ADSR — the deliberate back-compat path.
|
||||
ZonePlayParams play{PlayMode::Gate,
|
||||
AdsrParams{kTier0NominalAttackFrames, 0,
|
||||
kTier0NominalDecayFrames, kTier0NominalSustainLevel,
|
||||
kTier0NominalReleaseFrames},
|
||||
TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}};
|
||||
|
||||
// When true, the A/D/R frame counts in play.adsr are 44100-Hz nominals (either lifted from a
|
||||
// v3 blob or defaulted for a new zone) that must be rescaled by (sampleRate / 44100) at keymap
|
||||
// build time (buildZonedKeymap). Set to false when a v4 blob explicitly provides A/D/S/R (the
|
||||
// stored counts already reflect the DAW rate at the time the user edited them) or when the user
|
||||
// edits an ADSR slider (the committed value is already editor-domain). Never serialized.
|
||||
bool adsrNeedsRateResolve = true;
|
||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
|
||||
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
|
||||
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
|
||||
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
|
||||
ZonePlaySeconds play;
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
@@ -217,15 +229,7 @@ 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{kTier0NominalAttackFrames, 0,
|
||||
kTier0NominalDecayFrames, kTier0NominalSustainLevel,
|
||||
kTier0NominalReleaseFrames},
|
||||
TriggerParams{}, kDefaultPitchEngine,
|
||||
PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is)
|
||||
// Carried from PerformanceZone::adsrNeedsRateResolve — buildZonedKeymap rescales A/D/R
|
||||
// frames by (sampleRate / 44100) when true. False for v4-explicit or user-edited values.
|
||||
bool adsrNeedsRateResolve = true;
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
};
|
||||
|
||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||
@@ -305,27 +309,39 @@ 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):
|
||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): 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):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage;
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||
// 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.
|
||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones 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.
|
||||
// * PAYLOAD v4 (S12 review fix): the same marker + payload version (== 4), THEN the v3 body
|
||||
// PLUS, appended to each zone record after the v3 pitch-env tail, the full per-zone A/D/S/R:
|
||||
// 8-byte LE adsr.attackFrames (int64); 8-byte LE adsr.decayFrames (int64);
|
||||
// 8-byte LE adsr.sustainLevel as an IEEE-754 double (bit-cast to u64 LE);
|
||||
// 8-byte LE adsr.releaseFrames (int64).
|
||||
// A v3 payload (no v4 A/D/S/R tail) lifts those fields to the tier-0 nominal defaults at
|
||||
// 44100 Hz (kTier0Nominal* constants) so a voice using the zone ADSR sounds bit-identical to
|
||||
// the pre-v4 build. Voice::start now uses the zone's full ADSR for all five AHDSR fields.
|
||||
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
|
||||
// written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at
|
||||
// 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate
|
||||
// (kLegacyV3NominalRate). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
|
||||
// are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060), no rate.
|
||||
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
|
||||
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
|
||||
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||
// 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
|
||||
@@ -345,9 +361,15 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
// (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 = 4; // S15/S16 A/D/S/R per-zone tail
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// The authoring-time nominal rate the LEGACY v3 zone payload's wall-clock frame counts (hold,
|
||||
// pitchEnv A/D) were always written at (the S15/S16 editor's slider domain was fixed at 44100 Hz).
|
||||
// Used ONLY at the v3 read boundary to convert those legacy frames to the seconds domain — it is a
|
||||
// property of the frozen v3 wire format, not a live program rate. No other site may reference it.
|
||||
inline constexpr double kLegacyV3NominalRate = 44100.0;
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
@@ -364,12 +386,13 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
//
|
||||
// Format (v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
||||
// Format (envelope v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker),
|
||||
// then a 4-byte LE selection-id length + id bytes, then the v2 zones payload (4-byte LE zone
|
||||
// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is
|
||||
// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched
|
||||
// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on
|
||||
// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field,
|
||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||
// versioning; the two version numbers are independent axes). BACK-COMPAT on
|
||||
// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
|
||||
// preserving current behavior for already-saved instances):
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
|
||||
|
||||
+10
-13
@@ -249,8 +249,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
shiftR_.configure(windowFrames);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
const AdsrParams& gateAdsr) {
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) {
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
@@ -279,15 +278,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
||||
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
|
||||
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
|
||||
//
|
||||
// All five AHDSR fields come from sample.play.adsr, stamped by buildTier0Keymap /
|
||||
// buildZonedKeymap at reload time (with rate-rescaling for v3-lifted / default zones).
|
||||
// gateAdsr (the VoiceEngine's instrument-wide ADSR) is accepted for interface compat
|
||||
// but is NOT read here — it is vestigial since the S12 review fix moved A/D/S/R fully
|
||||
// onto the per-zone SampleData.
|
||||
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
|
||||
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
|
||||
// the live sample rate.
|
||||
//
|
||||
// Back-compat invariant: a zone whose adsr fields carry the tier-0 nominal values
|
||||
// (rescaled to the live sample rate by buildZonedKeymap) sounds bit-identical to the
|
||||
// pre-fix build at every DAW rate. ---
|
||||
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
|
||||
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
|
||||
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
@@ -488,9 +485,9 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
const AdsrParams& adsr, std::size_t preserveVoiceCap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames)
|
||||
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr),
|
||||
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap),
|
||||
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).
|
||||
@@ -561,7 +558,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
// 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].start(note, velocity, sample, zone.rootNote);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
+22
-25
@@ -341,18 +341,15 @@ 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`. All five AHDSR fields (A/H/D/S/R) are read directly from
|
||||
// sample.play.adsr — the per-zone values stamped by buildTier0Keymap / buildZonedKeymap.
|
||||
// `gateAdsr` is the VoiceEngine's instrument-wide ADSR parameter, accepted for interface
|
||||
// compatibility but NOT used by start() (vestigial since the S12 review fix moved A/D/S/R
|
||||
// onto the per-zone SampleData). 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& gateAdsr);
|
||||
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
|
||||
// buildTier0Keymap / buildZonedKeymap against the live sample rate. 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);
|
||||
|
||||
// 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).
|
||||
@@ -456,18 +453,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). `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,
|
||||
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
|
||||
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
|
||||
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
|
||||
// `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,
|
||||
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
|
||||
@@ -524,7 +522,6 @@ private:
|
||||
|
||||
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