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:
2026-07-27 02:51:46 -04:00
parent 1d338318e7
commit a54af277e4
10 changed files with 539 additions and 433 deletions
+30 -34
View File
@@ -298,19 +298,19 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
namespace { namespace {
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is // 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 // engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider // [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only // resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
// because the host rate is not reachable inside the editor without a processor callback, and the // span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one // wall-clock second). Build-time residual — one place to retune; not persisted.
// place to retune; not persisted. constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal) constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered 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); } double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace } // namespace
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const { std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
std::vector<ControlDesc> out; std::vector<ControlDesc> out;
// Always: the two mode toggles. // Always: the two mode toggles.
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle}); out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
@@ -335,24 +335,27 @@ std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& pl
return out; 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) { 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)) { switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; 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::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames); case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames); case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames); case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); 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::kTrigLength: return clamp01(play.trigger.lengthFraction);
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return framesToNorm(play.pitchEnv.attackFrames); case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames); case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
case ParamControl::kPitchEnvDepth: case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones). // Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); 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 { int segment) const {
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [](double v) { 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)) { switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: case ParamControl::kPlayMode:
@@ -372,11 +376,11 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
case ParamControl::kPitchEngine: case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break; break;
case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break; case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break; case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break; case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(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: case ParamControl::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. // 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)); 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: case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1); play.pitchEnv.enabled = (segment == 1);
break; break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break; case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break; case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvDepth: case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break; 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 // 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; // (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). // see paintZones for the gate that reaches here).
ZonePlayParams play; ZonePlaySeconds play;
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) { if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play; play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
} else if (map_.zones.empty() && !selectedId_.empty()) { } 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 // Synthesize a probe layout with the product defaults to see if the click is in the
// panel before committing to creating the zone. // panel before committing to creating the zone.
const Rect panel = zonesControlPanel(bands); 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<ControlDesc> probeDescs = controlDescs(defaultPlay);
const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs); const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs);
if (controlAtPoint(probeRows, x, y) >= 0) { if (controlAtPoint(probeRows, x, y) >= 0) {
@@ -1321,14 +1325,6 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
dragParamPanel_ = panel; dragParamPanel_ = panel;
dragStartMap_ = map_; dragStartMap_ = map_;
applyControl(id, z.play, valueAtPoint(r.control, x), 0); 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 invalidate(); // live feedback; commit on WM_LBUTTONUP
} }
break; break;
+9 -7
View File
@@ -178,22 +178,24 @@ private:
// --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------
// //
// The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16 // The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16
// pitch engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact. // 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 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 // 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 // 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. // 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 // 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). // mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
double controlValue(int id, const ZonePlayParams& play) const; // 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 // 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. // into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
void applyControl(int id, ZonePlayParams& play, double value, int segment) const; void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
ReaSamplerProcessor* processor_ = nullptr; ReaSamplerProcessor* processor_ = nullptr;
+4 -17
View File
@@ -37,10 +37,6 @@ namespace {
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the // 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 // dynamics), a short release for a natural tail. Times are in seconds, converted to
// frames against the live sample rate at build time. // 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; constexpr std::size_t kMaxVoices = 16;
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is // 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. // cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
constexpr std::size_t kPreserveVoiceCap = 8; 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 // 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". // any failure — the caller treats an unreadable WAV as "nothing to play".
std::vector<std::uint8_t> readFileBytes(const std::string& path) { std::vector<std::uint8_t> readFileBytes(const std::string& path) {
@@ -384,12 +370,13 @@ std::string ReaSamplerProcessor::reloadFromBank() {
if (haveKeymap) { if (haveKeymap) {
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). // 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 // 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>( 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; if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>( built = std::make_unique<LoadedInstrument>(
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap, std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
preserveWindow); preserveWindow);
} }
} }
+2 -2
View File
@@ -50,11 +50,11 @@ struct LoadedInstrument {
VoiceEngine engine; VoiceEngine engine;
std::uint64_t installedAt = 0; // reload generation at which this was installed std::uint64_t installedAt = 0; // reload generation at which this was installed
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr, LoadedInstrument(Keymap km, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0, std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0) std::int64_t preserveWindowFrames = 0)
: keymap(std::move(km)), : keymap(std::move(km)),
engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames), engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
installedAt(gen) {} installedAt(gen) {}
LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument(const LoadedInstrument&) = delete;
+83 -84
View File
@@ -133,9 +133,35 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
return out; 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, Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop, int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlayParams& play) { std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
SampleData data; SampleData data;
data.frames = std::move(frames); data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays // A second channel only counts when it length-matches channel 0 (else the sample stays
@@ -146,23 +172,8 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
data.rootNote = rootNote; data.rootNote = rootNote;
data.loop = loop; data.loop = loop;
data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
data.play = resolvePlay(play, data.sampleRate);
// 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);
}
return Keymap::singleSampleChromatic(std::move(data)); 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). // never mutated — this only shapes what the core plays for THIS instance (D-B).
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
rz.startFrame = z.startPoint ? *z.startPoint : 0; rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params carry through unchanged (they are instrument state, not // S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
// resolved against the bank) so the keymap build can stamp them onto the SampleData. // state, not resolved against the bank); buildZonedKeymap resolves them to frames.
rz.play = z.play; 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)); out.zones.push_back(std::move(rz));
} }
return out; return out;
@@ -233,21 +242,9 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
data.rootNote = zones[i].rootNote; data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop; data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes // 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.
// If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve), data.play = resolvePlay(zones[i].play, data.sampleRate);
// 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);
}
const std::size_t sampleIndex = km.samples.size(); const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data)); km.samples.push_back(std::move(data));
KeyZone zone; KeyZone zone;
@@ -376,26 +373,25 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
out.push_back(z.startPoint ? 1 : 0); out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint)); if (z.startPoint) putU64le(out, asU64(*z.startPoint));
// S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
// has a play mode + engine — no flag gate). Order matches the header's v3 record spec. // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
const ZonePlayParams& pp = z.play; // fraction. Order matches the header's v5 record spec.
const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, asU64(pp.adsr.holdFrames)); putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putU64le(out, asU64(pp.trigger.fadeInFrames)); putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
putU64le(out, asU64(pp.trigger.fadeOutFrames)); putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0); out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putU64le(out, asU64(pp.pitchEnv.attackFrames)); putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putU64le(out, asU64(pp.pitchEnv.decayFrames)); putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4). // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
// Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults putU64le(out, doubleToBits(pp.adsr.attackSeconds));
// at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build. putU64le(out, doubleToBits(pp.adsr.decaySeconds));
putU64le(out, asU64(pp.adsr.attackFrames));
putU64le(out, asU64(pp.adsr.decayFrames));
putU64le(out, doubleToBits(pp.adsr.sustainLevel)); 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 // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest. // keeps the zones that parsed cleanly and drops the rest.
void readZonesPayload(ByteReader& r, PerformanceMap& map) { void readZonesPayload(ByteReader& r, PerformanceMap& map) {
bool extended = false; // v2+: the S11 loop/start tail is present bool extended = false; // v2+: the S11 loop/start tail is present
bool hasPlay = false; // v3+: the S15/S16 play-params tail is present std::uint32_t pv = 0; // payload version (0 = v1, no marker)
bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present
if (r.peekU32() == kZonesFormatMarker) { if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker r.u32(); // consume the marker
const std::uint32_t pv = r.u32(); // payload version pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail extended = (pv >= 2); // v2+ carries the loop/start tail
hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail
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(); const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) { for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
// tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change. // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
PerformanceZone z; PerformanceZone z;
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen); z.sampleId = r.str(idLen);
@@ -438,37 +433,41 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
const std::uint8_t hasStart = r.u8(); const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64(); if (hasStart) z.startPoint = r.i64();
} }
if (hasPlay) { if (legacyV3Play) {
// S15/S16 play params, always present in a v3+ record (read in the emit order). // 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.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.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64(); z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64(); z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0); z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackFrames = r.i64(); z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.pitchEnv.decayFrames = r.i64(); z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); 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) { // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
// S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. // seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
// 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;
}
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z)); map.zones.push_back(std::move(z));
} }
+95 -72
View File
@@ -107,20 +107,50 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which); int channelCount, int which);
// Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload // --- Stored (wall-clock SECONDS) per-zone play params -------------------------
// 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 // DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
// and buildZonedKeymap both rescale the A/D/R frame counts by (sampleRate / 44100) at build // The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
// time when adsrNeedsRateResolve is set on the zone — so a v3-lifted or default zone plays with // engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
// the same wall-clock ADSR as tier0Adsr(liveRate) did before the fix. Bit-identical at 44100 Hz. // LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
// Zones loaded from a v4 blob (explicitly user-edited) carry adsrNeedsRateResolve=false and are // are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
// never rescaled their stored frame counts already reflect the rate at which they were authored. // Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
// Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone // and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
// (member initializers) — both of which reference these values. // frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded //
inline constexpr std::int64_t kTier0NominalDecayFrames = 0; // The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
inline constexpr double kTier0NominalSustainLevel = 1.0; struct AdsrSeconds {
inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 44100 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 // 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 // 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 // which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
// pair never half-plays. `sampleRate` is the WAV's rate. // pair never half-plays. `sampleRate` is the WAV's rate.
// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to // `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under // defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
// the same default engine as a zone would. The A/D/R frame counts in the default play arg are // picked single capture plays under the same default engine as a zone would. This function
// 44100-Hz nominals; this function always rescales them by (sampleRate / 44100) before stamping // resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
// them on the SampleData so the ADSR wall-clock durations match tier0Adsr(sampleRate) exactly.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate, Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop, int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {}, std::vector<AudioSample> framesR = {},
const ZonePlayParams& play = ZonePlayParams{ const ZonePlaySeconds& play = ZonePlaySeconds{});
PlayMode::Gate,
AdsrParams{kTier0NominalAttackFrames, 0,
kTier0NominalDecayFrames, kTier0NominalSustainLevel,
kTier0NominalReleaseFrames},
TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}});
// --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
// //
@@ -176,24 +200,12 @@ struct PerformanceZone {
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // 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 // 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, // loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
// AHDSR with the tier-0 nominal A/D/S/R (kTier0Nominal* at 44100 Hz — the same values a // resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
// v3 blob lifts to), hold 0, no fades, and the PRESERVE pitch engine (S16-F1), pitch env // zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
// off. An older zone-payload blob (no S15/S16 tail or no v4 A/D/S/R tail) lifts to exactly // PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
// these defaults on read (see the PAYLOAD v3/v4 versioning), so a pre-v4 instrument opens // lifts to exactly these defaults on read (see the PAYLOAD versioning).
// with Gate + Preserve + tier-0 ADSR — the deliberate back-compat path. ZonePlaySeconds play;
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;
}; };
// The instrument's performance map: an ordered list of zones. Order is authoritative for // The instrument's performance map: an ordered list of zones. Order is authoritative for
@@ -217,15 +229,7 @@ struct ResolvedZone {
int rootNote = 60; // effective: override, else bank intrinsic, else 60 int rootNote = 60; // effective: override, else bank intrinsic, else 60
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
ZonePlayParams play{PlayMode::Gate, ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
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;
}; };
// The result of resolving a performance map against the live bank blob. `zones` are the // The result of resolving a performance map against the live bank blob. `zones` are the
@@ -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). // 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads // The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. // the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS, // * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play // version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
// params — always present, NOT flag-gated, since every zone has a play mode + engine): // tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
// 1 byte playMode (0 = Gate, 1 = Trigger); // 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.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); // 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64); // 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double. // 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 + // 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 // 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. // 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 // LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// PLUS, appended to each zone record after the v3 pitch-env tail, the full per-zone A/D/S/R: // written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at
// 8-byte LE adsr.attackFrames (int64); 8-byte LE adsr.decayFrames (int64); // 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate
// 8-byte LE adsr.sustainLevel as an IEEE-754 double (bit-cast to u64 LE); // (kLegacyV3NominalRate). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
// 8-byte LE adsr.releaseFrames (int64). // are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060), no rate.
// A v3 payload (no v4 A/D/S/R tail) lifts those fields to the tier-0 nominal defaults at // * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// 44100 Hz (kTier0Nominal* constants) so a voice using the zone ADSR sounds bit-identical to // THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// the pre-v4 build. Voice::start now uses the zone's full ADSR for all five AHDSR fields. // 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 // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
@@ -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. // (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 // The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in
// practice, always tiny) can never collide with. // 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; 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). // The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map); 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 // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1. // 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), // 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 // then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to
// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is // serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched // The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field,
// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on // 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, // read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
// preserving current behavior for already-saved instances): // preserving current behavior for already-saved instances):
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct. // * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
+10 -13
View File
@@ -249,8 +249,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
shiftR_.configure(windowFrames); shiftR_.configure(windowFrames);
} }
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) {
const AdsrParams& gateAdsr) {
active_ = true; active_ = true;
releasing_ = false; releasing_ = false;
amplitudeDone_ = 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 // --- 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. // 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 / // All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
// buildZonedKeymap at reload time (with rate-rescaling for v3-lifted / default zones). // buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
// gateAdsr (the VoiceEngine's instrument-wide ADSR) is accepted for interface compat // the live sample rate.
// but is NOT read here — it is vestigial since the S12 review fix moved A/D/S/R fully
// onto the per-zone SampleData.
// //
// Back-compat invariant: a zone whose adsr fields carry the tier-0 nominal values // Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
// (rescaled to the live sample rate by buildZonedKeymap) sounds bit-identical to the // (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
// pre-fix build at every DAW rate. --- // every DAW rate — now trivially true, since the times are wall-clock seconds. ---
if (playMode_ == PlayMode::Gate) { if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr); env_.configure(p.adsr);
env_.noteOn(); env_.noteOn();
@@ -488,9 +485,9 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
const AdsrParams& adsr, std::size_t preserveVoiceCap, std::size_t preserveVoiceCap,
std::int64_t preserveWindowFrames) std::int64_t preserveWindowFrames)
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr), : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap),
preserveVoiceCap_(preserveVoiceCap) { preserveVoiceCap_(preserveVoiceCap) {
// maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
// clamp to a single voice so the engine is always usable (documented degenerate). // clamp to a single voice so the engine is always usable (documented degenerate).
@@ -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 // 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. // start() only reset()s + warm()s them — no allocation on this audio-thread path.
const std::size_t v = allocateVoice(); const std::size_t v = allocateVoice();
voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); voices_[v].start(note, velocity, sample, zone.rootNote);
voices_[v].setStartOrder(nextStartOrder_++); voices_[v].setStartOrder(nextStartOrder_++);
return v; return v;
} }
+22 -25
View File
@@ -341,18 +341,15 @@ public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched // 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 // 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. // sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
// `gateAdsr` is the VoiceEngine's instrument-wide ADSR parameter, accepted for interface // buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
// compatibility but NOT used by start() (vestigial since the S12 review fix moved A/D/S/R // Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
// onto the per-zone SampleData). The S15 play MODE + Trigger params and the S16 pitch // The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
// ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST already // start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
// be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s // thread inside process(). The warm silence pass settles the OLA taps before the first
// them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm // output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
// silence pass settles the OLA taps before the first output frame (no cold-start click). // is default (Gate + Varispeed + no pitch env).
// Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no void start(int note, int velocity, const SampleData& sample, int rootNote);
// pitch env).
void start(int note, int velocity, const SampleData& sample, int rootNote,
const AdsrParams& gateAdsr);
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in // 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). // 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: public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from // Builds an engine with `maxVoices` voices (the polyphony bound) playing from
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it // `keymap`. The keymap must outlive the engine (the engine holds a reference — it
// reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide // reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
// Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode + // + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
// pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many // from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
// Preserve-engine voices may sound at once (the shifter is materially heavier than // `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
// Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means // shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
// "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA // dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
// window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at // maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
// construction (OFF the audio thread), so note-on (which runs in process()) never allocates; // Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
// 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor // note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
// derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers // Varispeed-only instrument pays no ring cost). The processor derives it from the host
// (and the pure-core tests) are unaffected. // sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr, // are unaffected.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0); std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
@@ -524,7 +522,6 @@ private:
std::vector<Voice> voices_; std::vector<Voice> voices_;
const Keymap& keymap_; const Keymap& keymap_;
AdsrParams adsr_;
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap) std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
}; };
+227 -121
View File
@@ -1031,37 +1031,157 @@ static void testComponentStateV5TruncatedMarker() {
CHECK(back.selectionId.empty() && back.map.zones.empty()); CHECK(back.selectionId.empty() && back.map.zones.empty());
} }
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
//
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first
// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together.
static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() {
// (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying
// S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions
// sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with
// no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field
PerformanceZone z = zone("z0", 0, 127, /*override=*/48);
z.play.playMode = PlayMode::Trigger;
z.play.adsr.holdSeconds = 0.093;
z.play.trigger.lengthFraction = 0.625;
z.play.trigger.fadeInFrames = 32;
z.play.trigger.fadeOutFrames = 96;
z.play.pitchEngine = PitchEngine::Varispeed;
z.play.pitchEnv.enabled = true;
z.play.pitchEnv.attackSeconds = 0.00018;
z.play.pitchEnv.decaySeconds = 0.0145;
z.play.pitchEnv.peakSemitones = 12.5;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params
CHECK(p.playMode == PlayMode::Trigger);
CHECK(p.adsr.holdSeconds == 0.093);
CHECK(p.trigger.lengthFraction == 0.625);
CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96);
CHECK(p.pitchEngine == PitchEngine::Varispeed);
CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 &&
p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5);
}
// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the
// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/
// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs
// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain
// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer.
static std::vector<std::uint8_t> handBuildV3PayloadOneZone(const std::string& id) {
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
auto u64 = [&](std::uint64_t v) {
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((v >> (i * 8)) & 0xFF));
};
auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); };
u32(kZonesFormatMarker);
u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present)
u32(1); // zone count 1
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); // lowNote
u32(70); // highNote
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0
// Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off.
b.push_back(1); // playMode = Trigger
u64(static_cast<std::uint64_t>(2048)); // adsr.holdFrames
dbl(0.5); // trigger.lengthFraction
u64(static_cast<std::uint64_t>(16)); // trigger.fadeInFrames
u64(static_cast<std::uint64_t>(48)); // trigger.fadeOutFrames
b.push_back(0); // pitchEngine = Varispeed
b.push_back(0); // pitchEnv.enabled = 0
u64(0); // pitchEnv.attackFrames
u64(0); // pitchEnv.decayFrames
dbl(0.0); // pitchEnv.peakSemitones
return b;
}
static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
// (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones
// payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build
// save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to
// 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who
// saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates
// zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so
// the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished
// inside the payload, not on the envelope.
std::vector<std::uint8_t> v4;
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4
v4.push_back(1); // channel mode = stereo
const std::string id = "s15saved";
v4.push_back(static_cast<std::uint8_t>(id.size()));
v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE)
v4.insert(v4.end(), id.begin(), id.end());
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("zv3");
v4.insert(v4.end(), payload.begin(), payload.end());
const ComponentState back = deserializeComponentState(v4);
CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0
CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored
CHECK(back.selectionId == "s15saved");
CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check
if (back.map.zones.size() != 1) return;
CHECK(back.map.zones[0].sampleId == "zv3");
CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70);
const ZonePlaySeconds& p = back.map.zones[0].play;
CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope
// Legacy v3 wall-clock frames (44.1k-nominal) convert to seconds at the v3 authoring rate.
CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate));
CHECK(p.trigger.lengthFraction == 0.5);
CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is
CHECK(p.pitchEngine == PitchEngine::Varispeed);
CHECK(p.pitchEnv.enabled == false);
}
// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- // --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------
static void testPlayParamsRoundTrip() { static void testPlayParamsRoundTrip() {
// A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine, // A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades,
// pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail. // Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail.
PerformanceMap m; PerformanceMap m;
PerformanceZone z = zone("lead", 20, 100, /*override=*/55); PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
z.play.playMode = PlayMode::Trigger; z.play.playMode = PlayMode::Trigger;
z.play.adsr.holdFrames = 1234; z.play.adsr.holdSeconds = 0.028; // wall-clock seconds
z.play.trigger.lengthFraction = 0.375; z.play.trigger.lengthFraction = 0.375;
z.play.trigger.fadeInFrames = 64; z.play.trigger.fadeInFrames = 64; // source frames
z.play.trigger.fadeOutFrames = 128; z.play.trigger.fadeOutFrames = 128;
z.play.pitchEngine = PitchEngine::Varispeed; z.play.pitchEngine = PitchEngine::Varispeed;
z.play.pitchEnv.enabled = true; z.play.pitchEnv.enabled = true;
z.play.pitchEnv.attackFrames = 10; z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds
z.play.pitchEnv.decayFrames = 500; z.play.pitchEnv.decaySeconds = 0.011;
z.play.pitchEnv.peakSemitones = -7.5; z.play.pitchEnv.peakSemitones = -7.5;
m.zones.push_back(z); m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m)); const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones.size() == 1); CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return; if (back.zones.size() != 1) return;
const ZonePlayParams& p = back.zones[0].play; const ZonePlaySeconds& p = back.zones[0].play;
CHECK(p.playMode == PlayMode::Trigger); CHECK(p.playMode == PlayMode::Trigger);
CHECK(p.adsr.holdFrames == 1234); CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip
CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip
CHECK(p.trigger.fadeInFrames == 64); CHECK(p.trigger.fadeInFrames == 64);
CHECK(p.trigger.fadeOutFrames == 128); CHECK(p.trigger.fadeOutFrames == 128);
CHECK(p.pitchEngine == PitchEngine::Varispeed); CHECK(p.pitchEngine == PitchEngine::Varispeed);
CHECK(p.pitchEnv.enabled == true); CHECK(p.pitchEnv.enabled == true);
CHECK(p.pitchEnv.attackFrames == 10); CHECK(p.pitchEnv.attackSeconds == 0.0002);
CHECK(p.pitchEnv.decayFrames == 500); CHECK(p.pitchEnv.decaySeconds == 0.011);
CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip
} }
@@ -1073,7 +1193,7 @@ static void testPlayParamsComposeWithLoopStart() {
z.loopOverride = lp; z.loopOverride = lp;
z.startPoint = 333; z.startPoint = 333;
z.play.playMode = PlayMode::Gate; z.play.playMode = PlayMode::Gate;
z.play.adsr.holdFrames = 999; z.play.adsr.holdSeconds = 0.0225;
z.play.pitchEngine = PitchEngine::Preserve; z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z); m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m)); const PerformanceMap back = deserializePerformance(serializePerformance(m));
@@ -1082,7 +1202,7 @@ static void testPlayParamsComposeWithLoopStart() {
CHECK(back.zones[0].loopOverride.has_value() && CHECK(back.zones[0].loopOverride.has_value() &&
back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
CHECK(back.zones[0].play.adsr.holdFrames == 999); CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225);
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
} }
@@ -1115,7 +1235,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() {
CHECK(back.zones[0].play.playMode == PlayMode::Gate); CHECK(back.zones[0].play.playMode == PlayMode::Gate);
CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve
CHECK(back.zones[0].play.pitchEnv.enabled == false); CHECK(back.zones[0].play.pitchEnv.enabled == false);
CHECK(back.zones[0].play.adsr.holdFrames == 0); CHECK(back.zones[0].play.adsr.holdSeconds == 0.0);
} }
static void testPlayParamsThroughComponentEnvelope() { static void testPlayParamsThroughComponentEnvelope() {
@@ -1138,44 +1258,40 @@ static void testPlayParamsThroughComponentEnvelope() {
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
} }
// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. --------------------- // --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. ---
// //
// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized; // These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free);
// only holdFrames was written. These two tests assert the corrected v4 path. // the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift ->
// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker).
// All five AHDSR fields (including the four new A/D/S/R) must round-trip through the v4 payload. // All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip).
static void testFullAdsrV4RoundTrip() { static void testFullAdsrSecondsRoundTrip() {
PerformanceMap m; PerformanceMap m;
PerformanceZone z = zone("pad", 0, 127); PerformanceZone z = zone("pad", 0, 127);
z.play.playMode = PlayMode::Gate; z.play.playMode = PlayMode::Gate;
z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value) z.play.adsr.attackSeconds = 0.01;
z.play.adsr.holdFrames = 882; z.play.adsr.holdSeconds = 0.02;
z.play.adsr.decayFrames = 4410; // 0.1 s z.play.adsr.decaySeconds = 0.1;
z.play.adsr.sustainLevel = 0.7; z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseFrames = 8820; // 0.2 s z.play.adsr.releaseSeconds = 0.2;
z.play.pitchEngine = PitchEngine::Preserve; z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z); m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m)); const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones.size() == 1); CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return; if (back.zones.size() != 1) return;
const AdsrParams& a = back.zones[0].play.adsr; const AdsrSeconds& a = back.zones[0].play.adsr;
CHECK(a.attackFrames == 441); CHECK(a.attackSeconds == 0.01);
CHECK(a.holdFrames == 882); CHECK(a.holdSeconds == 0.02);
CHECK(a.decayFrames == 4410); CHECK(a.decaySeconds == 0.1);
CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast
CHECK(a.releaseFrames == 8820); CHECK(a.releaseSeconds == 0.2);
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
} }
// A genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift // A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the
// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults // absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they
// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior. // were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count.
// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload. static void testV3BlobLiftsAdsrToSecondsDefaults() {
static void testV3BlobLiftsAdsrToNominalDefaults() {
// Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload.
// deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload,
// which self-selects the v3 record shape from the payload marker+version — exercising the
// real production lift path for a user who saved on the S15 build.
std::vector<std::uint8_t> blob; std::vector<std::uint8_t> blob;
auto u32 = [&](std::uint32_t v) { auto u32 = [&](std::uint32_t v) {
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
@@ -1187,91 +1303,81 @@ static void testV3BlobLiftsAdsrToNominalDefaults() {
const PerformanceMap back = deserializePerformance(blob); const PerformanceMap back = deserializePerformance(blob);
CHECK(back.zones.size() == 1); CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return; if (back.zones.size() != 1) return;
const AdsrParams& a = back.zones[0].play.adsr; const AdsrSeconds& a = back.zones[0].play.adsr;
// holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values. // hold converts from the v3 record's 44.1k-nominal frames; A/D/S/R lift to the seconds defaults.
CHECK(a.holdFrames == 2048); // from the hand-built v3 record CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // from the hand-built v3 record
CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100) CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds)
CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0
CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0
CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060
// The lifted zone must be flagged for rate-resolve so buildZonedKeymap rescales at the live rate.
CHECK(back.zones[0].adsrNeedsRateResolve == true);
} }
// A v4 blob (explicit A/D/S/R tail) must NOT set adsrNeedsRateResolve — those values // The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone
// were authored at the DAW's rate and must not be rescaled again at keymap build time. // resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate));
static void testV4BlobClearsAdsrNeedsRateResolve() { // an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates.
PerformanceMap m; static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
PerformanceZone z = zone("pad", 0, 127); const auto rnd = [](double s, int rate) {
z.play.adsr.attackFrames = 441; return static_cast<std::int64_t>(s * static_cast<double>(rate) + 0.5);
z.play.adsr.releaseFrames = 8820; };
m.zones.push_back(z); for (int rate : {44100, 48000, 96000}) {
// A round-trip through serialize/deserialize writes a v4 payload (current version). // (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds.
const PerformanceMap back = deserializePerformance(serializePerformance(m)); {
CHECK(back.zones.size() == 1); PerformanceMap m;
if (back.zones.size() != 1) return; m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds)
// v4 tail was explicitly read — adsrNeedsRateResolve must be false. const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones[0].adsrNeedsRateResolve == false); CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) continue;
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
rz.play = back.zones[0].play;
const DecodedZonePcm pcm{{0.5f}, rate};
const Keymap km = buildZonedKeymap({rz}, {pcm});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) continue;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate
CHECK(a.decayFrames == 0);
CHECK(a.sustainLevel == 1.0); // level, never rate-scaled
CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate
}
// (b) AUTHORED zone -> round(seconds * rate) at this rate.
{
PerformanceMap m;
PerformanceZone z = zone("auth", 0, 127);
z.play.adsr.attackSeconds = 0.01;
z.play.adsr.decaySeconds = 0.1;
z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseSeconds = 0.2;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) continue;
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
rz.play = back.zones[0].play;
const DecodedZonePcm pcm{{0.5f}, rate};
const Keymap km = buildZonedKeymap({rz}, {pcm});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) continue;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == rnd(0.01, rate));
CHECK(a.decayFrames == rnd(0.1, rate));
CHECK(a.sustainLevel == 0.7); // level, never rate-scaled
CHECK(a.releaseFrames == rnd(0.2, rate));
}
}
} }
// buildTier0Keymap at 48k must produce ADSR frame counts equal to tier0Adsr(48000): // buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the
// attack = round(kTier0NominalAttackFrames * 48000 / 44100) = round(143.67) = 144, // single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release
// release = round(kTier0NominalReleaseFrames * 48000 / 44100) = round(2880.0) = 2880. // round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant.
// This is the "pre-fix, the Gate voice used tier0Adsr(sampleRate_)" invariant restored static void testBuildTier0KeymapResolvesSecondsAt48k() {
// for the single-capture fast path at any DAW rate.
static void testBuildTier0KeymapRescalesAdsrAt48k() {
const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{}); const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{});
CHECK(km.samples.size() == 1); CHECK(km.samples.size() == 1);
if (km.samples.empty()) return; if (km.samples.empty()) return;
const AdsrParams& a = km.samples[0].play.adsr; const AdsrParams& a = km.samples[0].play.adsr;
// Rescaled from 44100-nominal at 48000 Hz: CHECK(a.attackFrames == 144); // round(0.003 * 48000)
CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) CHECK(a.decayFrames == 0);
CHECK(a.decayFrames == 0); // 0 * factor = 0 (no change) CHECK(a.sustainLevel == 1.0); // level, not a time
CHECK(a.sustainLevel == 1.0); // level, not frames (no rescale) CHECK(a.releaseFrames == 2880); // round(0.060 * 48000)
CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0)
}
// buildZonedKeymap at 48k with adsrNeedsRateResolve=true must rescale ADSR to match
// tier0Adsr(48000), mirroring what Gate voices saw before the per-zone-ADSR fix.
static void testBuildZonedKeymapRescalesNominalAdsrAt48k() {
// Build a zone with nominal 44100-Hz ADSR and the needs-resolve flag (the default).
ResolvedZone z;
z.lowNote = 0; z.highNote = 127; z.rootNote = 60;
z.adsrNeedsRateResolve = true; // 44100-nominal values, rescale needed
z.play.adsr.attackFrames = kTier0NominalAttackFrames; // 132
z.play.adsr.decayFrames = kTier0NominalDecayFrames; // 0
z.play.adsr.sustainLevel = kTier0NominalSustainLevel; // 1.0
z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; // 2646
const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV
const Keymap km = buildZonedKeymap({z}, {pcm});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) return;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0)
CHECK(a.decayFrames == 0); // 0 * factor = 0
CHECK(a.sustainLevel == 1.0); // level, not rescaled
CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0)
}
// buildZonedKeymap must NOT rescale a zone whose adsrNeedsRateResolve is false —
// those frame counts are explicitly user-authored at the DAW's rate.
static void testBuildZonedKeymapDoesNotRescaleV4Adsr() {
ResolvedZone z;
z.lowNote = 0; z.highNote = 127; z.rootNote = 60;
z.adsrNeedsRateResolve = false; // v4 blob or user-edited — do not rescale
z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (non-nominal)
z.play.adsr.decayFrames = 4410;
z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseFrames = 8820;
const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV — rescale would change values
const Keymap km = buildZonedKeymap({z}, {pcm});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) return;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == 441); // unchanged (not rescaled)
CHECK(a.decayFrames == 4410);
CHECK(a.sustainLevel == 0.7);
CHECK(a.releaseFrames == 8820);
} }
int main() { int main() {
@@ -1325,12 +1431,10 @@ int main() {
testPlayParamsComposeWithLoopStart(); testPlayParamsComposeWithLoopStart();
testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsV2BackCompatLiftsToDefaults();
testPlayParamsThroughComponentEnvelope(); testPlayParamsThroughComponentEnvelope();
testFullAdsrV4RoundTrip(); testFullAdsrSecondsRoundTrip();
testV3BlobLiftsAdsrToNominalDefaults(); testV3BlobLiftsAdsrToSecondsDefaults();
testV4BlobClearsAdsrNeedsRateResolve(); testKeymapBuildResolvesSecondsToFramesAtEachRate();
testBuildTier0KeymapRescalesAdsrAt48k(); testBuildTier0KeymapResolvesSecondsAt48k();
testBuildZonedKeymapRescalesNominalAdsrAt48k();
testBuildZonedKeymapDoesNotRescaleV4Adsr();
testComponentStateRoundTrip(); testComponentStateRoundTrip();
testComponentStateLoopStartRoundTrip(); testComponentStateLoopStartRoundTrip();
testComponentStateSelectionOnlyNoZones(); testComponentStateSelectionOnlyNoZones();
@@ -1358,6 +1462,8 @@ int main() {
testComponentStateDefaultMarkerIsZero(); testComponentStateDefaultMarkerIsZero();
testComponentStateV4LiftsMarkerToZero(); testComponentStateV4LiftsMarkerToZero();
testComponentStateV5TruncatedMarker(); testComponentStateV5TruncatedMarker();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
if (g_fail == 0) std::printf("sample_map: all tests passed\n"); if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0; return g_fail != 0;
+57 -58
View File
@@ -149,7 +149,7 @@ static void testRepitchObservedPeriod() {
// Unity: played at root, observed period ~= native. // Unity: played at root, observed period ~= native.
{ {
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, frames); eng.render(out, frames);
@@ -159,7 +159,7 @@ static void testRepitchObservedPeriod() {
// +1 octave: advances 2x, observed period halves. // +1 octave: advances 2x, observed period halves.
{ {
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
eng.noteOn(72, 127); eng.noteOn(72, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, frames / 2); // half as many frames covers the whole sample eng.render(out, frames / 2); // half as many frames covers the whole sample
@@ -169,7 +169,7 @@ static void testRepitchObservedPeriod() {
// -1 octave: advances 0.5x, observed period doubles. // -1 octave: advances 0.5x, observed period doubles.
{ {
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
eng.noteOn(48, 127); eng.noteOn(48, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, frames); eng.render(out, frames);
@@ -285,7 +285,7 @@ static void testAdsrZeroAttackDecay() {
static void testPolyphonicAllocation() { static void testPolyphonicAllocation() {
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
VoiceEngine eng(8, km, flatAdsr()); VoiceEngine eng(8, km);
// Four simultaneous notes -> four active voices, each on a distinct voice. // Four simultaneous notes -> four active voices, each on a distinct voice.
std::size_t v60 = eng.noteOn(60, 100); std::size_t v60 = eng.noteOn(60, 100);
@@ -329,10 +329,11 @@ static void testNoteOffReleasesNewestSameNote() {
const double gainOld = velOld / 127.0; // ~0.504 const double gainOld = velOld / 127.0; // ~0.504
const double gainNew = velNew / 127.0; // 1.0 const double gainNew = velNew / 127.0; // 1.0
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); SampleData sd = dcSample(100000, 60);
AdsrParams a = flatAdsr(); sd.play.adsr = flatAdsr();
a.releaseFrames = 10; // short but non-zero so voice stays active through release sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
VoiceEngine eng(8, km, a); Keymap km = Keymap::singleSampleChromatic(sd);
VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
@@ -371,7 +372,7 @@ static void testOutOfZoneNoteConsumesNoVoice() {
Keymap km; Keymap km;
km.samples.push_back(dcSample(100, 60)); km.samples.push_back(dcSample(100, 60));
km.zones.push_back(KeyZone{60, 72, 60, 0}); km.zones.push_back(KeyZone{60, 72, 60, 0});
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
std::size_t v = eng.noteOn(30, 100); // below the only zone std::size_t v = eng.noteOn(30, 100); // below the only zone
CHECK(v == VoiceEngine::kNoVoice); CHECK(v == VoiceEngine::kNoVoice);
@@ -383,14 +384,13 @@ static void testOutOfZoneNoteConsumesNoVoice() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static void testStealsReleasingVoiceFirst() { static void testStealsReleasingVoiceFirst() {
// Long per-zone release so the voice stays active through the release tail. // Long per-zone release so the voice stays active through the release tail. Voice::start reads
// Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr — // sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
// so the long release must live on the SampleData, not on the VoiceEngine constructor arg.
SampleData s = dcSample(100000, 60); SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr(); s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, flatAdsr()); VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 std::size_t vA = eng.noteOn(60, 100); // startOrder 1
std::size_t vB = eng.noteOn(62, 100); // startOrder 2 std::size_t vB = eng.noteOn(62, 100); // startOrder 2
@@ -415,7 +415,7 @@ static void testStealsOldestWhenNoneReleasing() {
s.play.adsr = flatAdsr(); s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, flatAdsr()); VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
std::size_t vB = eng.noteOn(62, 100); // startOrder 2 std::size_t vB = eng.noteOn(62, 100); // startOrder 2
@@ -453,7 +453,7 @@ static void testLoopSustainSeamless() {
s.loop.end = 40; s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -475,7 +475,7 @@ static void testZeroLengthLoopGoesSilent() {
s.loop.start = 25; s.loop.start = 25;
s.loop.end = 25; // zero length s.loop.end = 25; // zero length
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -499,7 +499,7 @@ static void testSingleFrameLoop() {
s.loop.end = 6; // single-frame loop: [5, 6) s.loop.end = 6; // single-frame loop: [5, 6)
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -518,7 +518,7 @@ static void testAbsentLoopGoesSilent() {
SampleData s = dcSample(50, 60); SampleData s = dcSample(50, 60);
// s.loop.hasLoop stays false. // s.loop.hasLoop stays false.
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 100); eng.render(out, 100);
@@ -539,7 +539,7 @@ static void testStartFrameOffsetsInitialRead() {
s.rootNote = 60; s.rootNote = 60;
s.startFrame = 30; s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 3); eng.render(out, 3);
@@ -555,7 +555,7 @@ static void testStartFrameZeroIsUnchanged() {
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f; for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0 s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 1); eng.render(out, 1);
@@ -568,7 +568,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
SampleData s = dcSample(10, 60); // 10 frames of 1.0 SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 5); eng.render(out, 5);
@@ -589,7 +589,7 @@ static void testStartFrameWithLoop() {
s.loop.start = 20; s.loop.start = 20;
s.loop.end = 40; s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 200); eng.render(out, 200);
@@ -618,7 +618,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
s.loop.end = 40; s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -644,21 +644,21 @@ static void testVelocityToVolume() {
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
// rendered value is exactly velocity/127 on a DC-1 sample). // rendered value is exactly velocity/127 on a DC-1 sample).
{ {
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 1); eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4)); CHECK(approx(out[0], 1.0, 1e-4));
} }
{ {
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 64); eng.noteOn(60, 64);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 1); eng.render(out, 1);
CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
} }
{ {
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 1); eng.noteOn(60, 1);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 1); eng.render(out, 1);
@@ -669,7 +669,7 @@ static void testVelocityToVolume() {
// Two voices summed: polyphony mixes additively. // Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() { static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
eng.noteOn(60, 127); // gain 1.0 eng.noteOn(60, 127); // gain 1.0
eng.noteOn(60, 127); // gain 1.0 (second voice, same note) eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -705,7 +705,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
// scaled by velocity (full here). If the engine copied L to both channels the R check fails. // scaled by velocity (full here). If the engine copied L to both channels the R check fails.
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f); std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
@@ -720,7 +720,7 @@ static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal // A MONO sample rendered through the stereo path plays dual-mono: both channels equal
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case. // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0 Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f); std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
eng.render(left.data(), right.data(), 8); eng.render(left.data(), right.data(), 8);
@@ -734,7 +734,7 @@ static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel. // whether or not a second channel is present. A stereo sample rendered mono == its L channel.
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60)); Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
VoiceEngine engS(1, kmS, flatAdsr()); VoiceEngine engS(1, kmS);
engS.noteOn(60, 127); engS.noteOn(60, 127);
std::vector<AudioSample> mono; std::vector<AudioSample> mono;
engS.render(mono, 8); // the mono overload engS.render(mono, 8); // the mono overload
@@ -759,7 +759,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
} }
s.rootNote = 60; s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
eng.noteOn(72, 127); // +1 octave eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f); std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
eng.render(left.data(), right.data(), frames / 2); eng.render(left.data(), right.data(), frames / 2);
@@ -770,7 +770,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
static void testStereoRenderSumsVoicesPerChannel() { static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60)); Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
VoiceEngine eng(4, km, flatAdsr()); VoiceEngine eng(4, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note eng.noteOn(60, 127); // second voice, same note
std::vector<AudioSample> left(1, 0.f), right(1, 0.f); std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
@@ -781,7 +781,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
static void testStereoRenderNullBufferIsNoOp() { static void testStereoRenderNullBufferIsNoOp() {
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f); std::vector<AudioSample> buf(4, 0.f);
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
@@ -810,7 +810,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
s.loop.end = 30; // loop [20,30): frames 20..29 s.loop.end = 30; // loop [20,30): frames 20..29
CHECK(s.channelCount() == 2); CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> left(200, 0.f), right(200, 0.f); std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
@@ -908,7 +908,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
static void testTriggerLengthFractionFrames() { static void testTriggerLengthFractionFrames() {
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio eng.noteOn(60, 127); // unity ratio
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 200); eng.render(out, 200);
@@ -922,7 +922,7 @@ static void testTriggerLengthFractionFrames() {
static void testTriggerLengthWithStart() { static void testTriggerLengthWithStart() {
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40)); Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 200); eng.render(out, 200);
@@ -936,7 +936,7 @@ static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20)); Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 120); eng.render(out, 120);
@@ -956,7 +956,7 @@ static void testTriggerEdgeCases() {
// %=0: zero play length -> voice frees at once, no sound. // %=0: zero play length -> voice frees at once, no sound.
{ {
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5)); Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 50); eng.render(out, 50);
@@ -967,7 +967,7 @@ static void testTriggerEdgeCases() {
{ {
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30)); Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 50); eng.render(out, 50);
@@ -977,7 +977,7 @@ static void testTriggerEdgeCases() {
// %=100 plays the full post-start span. // %=100 plays the full post-start span.
{ {
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0)); Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 80); eng.render(out, 80);
@@ -989,7 +989,7 @@ static void testTriggerEdgeCases() {
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- // --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() { static void testTriggerIgnoresNoteOff() {
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 10); eng.render(out, 10);
@@ -1039,7 +1039,7 @@ static void testPreserveDurationInvariance() {
auto lengthAt = [&](int note) -> std::size_t { auto lengthAt = [&](int note) -> std::size_t {
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0)); Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window)); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
eng.noteOn(note, 127); eng.noteOn(note, 127);
return soundingLength(eng, 4000); return soundingLength(eng, 4000);
}; };
@@ -1066,7 +1066,7 @@ static void testVarispeedStillCouplesDuration() {
s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0; s.play.trigger.lengthFraction = 1.0;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(note, 127); eng.noteOn(note, 127);
return soundingLength(eng, 4000); return soundingLength(eng, 4000);
}; };
@@ -1091,7 +1091,7 @@ static void testPitchEnvOffBitIdentical() {
s.play.pitchEnv.decayFrames = 500; s.play.pitchEnv.decayFrames = 500;
} }
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path) eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, n); eng.render(out, n);
@@ -1119,7 +1119,7 @@ static void testPitchEnvOnBendsVarispeed() {
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 4000); eng.render(out, 4000);
@@ -1154,7 +1154,7 @@ static void testPreserveGateStereoLoopComposes() {
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
CHECK(s.channelCount() == 2); CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr(), 0, 512); VoiceEngine eng(1, km, 0, 512);
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held) eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f); std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
eng.render(left.data(), right.data(), 2000); eng.render(left.data(), right.data(), 2000);
@@ -1176,23 +1176,22 @@ static void testPreserveVoiceCap() {
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
// 8 voices total, Preserve cap of 2. // 8 voices total, Preserve cap of 2.
VoiceEngine eng(8, km, flatAdsr(), /*preserveCap=*/2, /*window=*/256); VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
CHECK(eng.activeVoiceCount() == 2); CHECK(eng.activeVoiceCount() == 2);
} }
// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. --- // --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
// //
// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only // Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
// folded the per-zone holdFrames. These two tests assert the corrected path. // keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.
// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr. // The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but put an explicit // the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
// 10-frame attack on the SampleData.play.adsr. If Voice::start reads the zone ADSR, the // 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
// DC-1 output will be 0 at frame 0 and 1.0 after the 10-frame ramp. If it instead used // 1.0 at frame 0. This is the load-bearing proof.
// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof.
static void testPerZoneAdsrReachesVoiceEnvelope() { static void testPerZoneAdsrReachesVoiceEnvelope() {
SampleData s = dcSample(500, 60); SampleData s = dcSample(500, 60);
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release. // Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
@@ -1203,11 +1202,11 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
s.play.adsr.releaseFrames = 0; s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack) VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 20); eng.render(out, 20);
// Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0. // Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9). // Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
CHECK(out[9] < 1.0 - 1e-9); CHECK(out[9] < 1.0 - 1e-9);
@@ -1226,7 +1225,7 @@ static void testZeroAdsrIsInstantSustain() {
s.play.adsr = AdsrParams{}; s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 5); eng.render(out, 5);