fix(trigger-seam): thread startFrame into both envelope converters; extract pure triggerPlayLength module + tests

This commit is contained in:
2026-07-27 14:37:05 -04:00
parent c12a374b88
commit 8a7dc4b1c6
7 changed files with 296 additions and 40 deletions
+26 -33
View File
@@ -27,6 +27,7 @@
#include "app_version.h" // vstPluginName (channel-derived editor title band, S18)
#include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
#include "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3)
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
#ifdef _WIN32
@@ -462,19 +463,8 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
return b;
}
// The played-span source-frame length for the Trigger fade fraction conversion: the post-start
// span scaled by lengthFraction (envelope_overlay.h's TRIGGER SEAM math). `frames` is the total
// source frame count; startFrame comes from the zone's startPoint (0 when unset).
namespace {
std::int64_t triggerPlayLengthFrames(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) {
const std::int64_t postStart = (std::max)(std::int64_t{0}, frames - startFrame);
const double len = play.trigger.lengthFraction * static_cast<double>(postStart);
return static_cast<std::int64_t>(len + 0.5);
}
} // namespace
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames) const {
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const {
AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
@@ -484,21 +474,19 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
env.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played
// span (the TRIGGER SEAM converter, PACK direction). A zero play length yields 0 fractions.
// span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start
// point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction;
const std::int64_t playLen = triggerPlayLengthFrames(play, frames, /*startFrame=*/0);
if (playLen > 0) {
env.fadeInFraction = static_cast<double>(play.trigger.fadeInFrames) / static_cast<double>(playLen);
env.fadeOutFraction = static_cast<double>(play.trigger.fadeOutFrames) / static_cast<double>(playLen);
} else {
env.fadeInFraction = 0.0;
env.fadeOutFraction = 0.0;
}
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
return env;
}
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
ZonePlaySeconds& play) const {
std::int64_t startFrame, ZonePlaySeconds& play) const {
if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds;
@@ -507,14 +495,15 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
play.adsr.releaseSeconds = env.releaseSeconds;
} else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the TRIGGER SEAM converter, UNPACK direction). Keep the same (0,1] floor
// on lengthFraction the slider path enforces so a zero-length trigger never plays nothing.
// the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's
// effective start point so the frame denominator matches the voice's actual post-start span.
// Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length
// trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
const std::int64_t playLen = triggerPlayLengthFrames(play, frames, /*startFrame=*/0);
play.trigger.fadeInFrames =
static_cast<std::int64_t>(env.fadeInFraction * static_cast<double>(playLen) + 0.5);
play.trigger.fadeOutFrames =
static_cast<std::int64_t>(env.fadeOutFraction * static_cast<double>(playLen) + 0.5);
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
}
}
@@ -1056,7 +1045,8 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const AmpEnvelope env = packEnvelope(zone.play, frames);
const std::int64_t startFrame = zone.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
@@ -1687,7 +1677,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
const double rate = liveSampleRate();
if (rate > 0.0) {
const PerformanceZone zone = effectiveSampleZone();
const AmpEnvelope env = packEnvelope(zone.play, frames);
const std::int64_t startFrame = zone.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
if (nh.hit) {
@@ -1697,6 +1688,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartFrame_ = startFrame;
dragStartMap_ = map_;
return; // node moves once the cursor drags
}
@@ -1941,7 +1933,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
totalSeconds, envClampBounds(), dx, dy);
const int zi = ensureSampleZone();
if (zi >= 0) {
unpackEnvelope(edited, frames, map_.zones[static_cast<std::size_t>(zi)].play);
unpackEnvelope(edited, frames, dragStartFrame_,
map_.zones[static_cast<std::size_t>(zi)].play);
selectedZone_ = zi;
}
invalidate(); // live feedback; commit on WM_LBUTTONUP
+7 -2
View File
@@ -281,12 +281,16 @@ private:
// PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the
// Trigger fade fractions from the source-frame fades over the played span.
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames) const;
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const;
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params. Copies AHDSR seconds
// directly; converts the Trigger fade fractions back to source frames over the played span.
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
// Mutates `play` in place; only the mode-relevant fields are written.
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, ZonePlaySeconds& play) const;
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
ZonePlaySeconds& play) const;
// The clamp bounds envelope_edit uses, matching the control-panel sliders' own domains (so a
// node drag can never produce a param a slider couldn't — the S-VIEW-F2 invariant).
@@ -385,6 +389,7 @@ private:
WaveMarker waveMarker_ = WaveMarker::kStart;
SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
// from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the
+10 -4
View File
@@ -570,15 +570,21 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
// Preview note-on/off drive the SAME voice engine as host MIDI (a preview is just a note with
// no MIDI wire) — off-thread posted, audio-thread consumed, no lock, no allocation.
if (inst) {
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
// is loaded does not re-fire stale on the next instrument load.
{
const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
if (onSeq != 0 && onSeq != previewOnConsumed_) {
previewOnConsumed_ = onSeq;
const int vel = static_cast<int>((on >> 8) & 0xFF);
const int note = static_cast<int>(on & 0xFF);
if (vel > 0) inst->engine.noteOn(note, vel);
if (inst) {
const int vel = static_cast<int>((on >> 8) & 0xFF);
const int note = static_cast<int>(on & 0xFF);
if (vel > 0) inst->engine.noteOn(note, vel);
}
}
}
if (inst) {
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
if (offSeq != 0 && offSeq != previewOffConsumed_) {
+27
View File
@@ -0,0 +1,27 @@
// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h).
#include "trigger_seam.h"
#include <algorithm>
namespace reasampler::vst {
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame) {
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - startFrame);
if (postStart <= 0 || lengthFraction <= 0.0) return 0;
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
}
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) {
if (playLength <= 0) return 0.0;
return static_cast<double>(fadeFrames) / static_cast<double>(playLength);
}
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) {
if (playLength <= 0) return 0;
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
}
} // namespace reasampler::vst
+51
View File
@@ -0,0 +1,51 @@
// trigger_seam.h — PURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam.
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
//
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
// of Trigger fade lengths:
//
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
// that anchor directly to the voice's source-timeline read pointer.
//
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
// span, where the played span is:
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
// the engine stores frames so the voice advances correctly at the live rate.
//
// This module owns the one shared formula so the pack (frames->fractions) and unpack
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
//
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
// invariant (a drag can never produce a value a slider couldn't reach).
#pragma once
#include <cstdint>
namespace reasampler::vst {
// The source-frame length of the Trigger played span:
// postStart = max(0, frameCount - startFrame)
// playLength = round(lengthFraction * postStart)
// `frameCount` is the total decoded sample length in source frames.
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
// Returns 0 when postStart == 0 or lengthFraction <= 0.
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame);
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
// stays in envelope_edit, not here.
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::vst