fix(trigger-seam): thread startFrame into both envelope converters; extract pure triggerPlayLength module + tests
This commit is contained in:
+14
-1
@@ -762,6 +762,13 @@ add_library(param_slider STATIC src/vst/param_slider.cpp)
|
|||||||
target_include_directories(param_slider PUBLIC src/vst)
|
target_include_directories(param_slider PUBLIC src/vst)
|
||||||
target_link_libraries(param_slider PUBLIC editor_geometry)
|
target_link_libraries(param_slider PUBLIC editor_geometry)
|
||||||
|
|
||||||
|
# trigger_seam (Phase S-VIEW-3) — PURE Trigger-mode frames<->fraction converter for the TRIGGER
|
||||||
|
# SEAM documented in envelope_overlay.h: triggerPlayLength / framesToFadeFraction /
|
||||||
|
# fadeFractionToFrames. Owns the one formula so pack (draw) and unpack (commit) are provably
|
||||||
|
# consistent. No shell/LICE/REAPER types — only <cstdint>. NEITHER SDK.
|
||||||
|
add_library(trigger_seam STATIC src/vst/trigger_seam.cpp)
|
||||||
|
target_include_directories(trigger_seam PUBLIC src/vst)
|
||||||
|
|
||||||
# envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view
|
# envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view
|
||||||
# envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock
|
# envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock
|
||||||
# duration -> a breakpoint polyline in the waveform rect, at the same time base waveform_view maps.
|
# duration -> a breakpoint polyline in the waveform rect, at the same time base waveform_view maps.
|
||||||
@@ -835,6 +842,12 @@ add_executable(param_slider_tests tests/test_param_slider.cpp)
|
|||||||
target_link_libraries(param_slider_tests PRIVATE param_slider)
|
target_link_libraries(param_slider_tests PRIVATE param_slider)
|
||||||
add_test(NAME param_slider_tests COMMAND param_slider_tests)
|
add_test(NAME param_slider_tests COMMAND param_slider_tests)
|
||||||
|
|
||||||
|
# trigger_seam (S-VIEW-3): the pure Trigger-mode frames<->fraction converter.
|
||||||
|
# Links ONLY trigger_seam — no editor_geometry dep, the plainest data-boundary proof possible.
|
||||||
|
add_executable(trigger_seam_tests tests/test_trigger_seam.cpp)
|
||||||
|
target_link_libraries(trigger_seam_tests PRIVATE trigger_seam)
|
||||||
|
add_test(NAME trigger_seam_tests COMMAND trigger_seam_tests)
|
||||||
|
|
||||||
# envelope_overlay (S-VIEW-3): the pure amp-envelope -> polyline geometry (Gate AHDSR + Trigger
|
# envelope_overlay (S-VIEW-3): the pure amp-envelope -> polyline geometry (Gate AHDSR + Trigger
|
||||||
# fade/%-length) at the waveform time base. Links ONLY envelope_overlay (+ its editor_geometry
|
# fade/%-length) at the waveform time base. Links ONLY envelope_overlay (+ its editor_geometry
|
||||||
# dep) — NEITHER SDK — the plain-data-boundary proof.
|
# dep) — NEITHER SDK — the plain-data-boundary proof.
|
||||||
@@ -1057,7 +1070,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||||
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
||||||
waveform_view bank_sync browser_scroll note_entry param_slider
|
waveform_view bank_sync browser_scroll note_entry param_slider
|
||||||
theme component_geometry bank_grid envelope_overlay envelope_edit)
|
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit)
|
||||||
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
||||||
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
|
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
|
||||||
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "app_version.h" // vstPluginName (channel-derived editor title band, S18)
|
#include "app_version.h" // vstPluginName (channel-derived editor title band, S18)
|
||||||
#include "sample_map.h"
|
#include "sample_map.h"
|
||||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
|
#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)
|
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -462,19 +463,8 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The played-span source-frame length for the Trigger fade fraction conversion: the post-start
|
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||||
// span scaled by lengthFraction (envelope_overlay.h's TRIGGER SEAM math). `frames` is the total
|
std::int64_t startFrame) const {
|
||||||
// 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 env;
|
AmpEnvelope env;
|
||||||
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
|
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
|
||||||
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
|
// 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.sustainLevel = play.adsr.sustainLevel;
|
||||||
env.releaseSeconds = play.adsr.releaseSeconds;
|
env.releaseSeconds = play.adsr.releaseSeconds;
|
||||||
// Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played
|
// 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;
|
env.lengthFraction = play.trigger.lengthFraction;
|
||||||
const std::int64_t playLen = triggerPlayLengthFrames(play, frames, /*startFrame=*/0);
|
const std::int64_t playLen =
|
||||||
if (playLen > 0) {
|
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
|
||||||
env.fadeInFraction = static_cast<double>(play.trigger.fadeInFrames) / static_cast<double>(playLen);
|
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
|
||||||
env.fadeOutFraction = static_cast<double>(play.trigger.fadeOutFrames) / static_cast<double>(playLen);
|
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
|
||||||
} else {
|
|
||||||
env.fadeInFraction = 0.0;
|
|
||||||
env.fadeOutFraction = 0.0;
|
|
||||||
}
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
|
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) {
|
if (env.mode == EnvMode::Gate) {
|
||||||
play.adsr.attackSeconds = env.attackSeconds;
|
play.adsr.attackSeconds = env.attackSeconds;
|
||||||
play.adsr.holdSeconds = env.holdSeconds;
|
play.adsr.holdSeconds = env.holdSeconds;
|
||||||
@@ -507,14 +495,15 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
|||||||
play.adsr.releaseSeconds = env.releaseSeconds;
|
play.adsr.releaseSeconds = env.releaseSeconds;
|
||||||
} else {
|
} else {
|
||||||
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
|
// 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
|
// the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's
|
||||||
// on lengthFraction the slider path enforces so a zero-length trigger never plays nothing.
|
// 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);
|
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
|
||||||
const std::int64_t playLen = triggerPlayLengthFrames(play, frames, /*startFrame=*/0);
|
const std::int64_t playLen =
|
||||||
play.trigger.fadeInFrames =
|
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
|
||||||
static_cast<std::int64_t>(env.fadeInFraction * static_cast<double>(playLen) + 0.5);
|
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
|
||||||
play.trigger.fadeOutFrames =
|
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
|
||||||
static_cast<std::int64_t>(env.fadeOutFraction * static_cast<double>(playLen) + 0.5);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,7 +1045,8 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
|
|||||||
const double rate = liveSampleRate();
|
const double rate = liveSampleRate();
|
||||||
if (rate <= 0.0) return;
|
if (rate <= 0.0) return;
|
||||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
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);
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||||
|
|
||||||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
// 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();
|
const double rate = liveSampleRate();
|
||||||
if (rate > 0.0) {
|
if (rate > 0.0) {
|
||||||
const PerformanceZone zone = effectiveSampleZone();
|
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 double totalSeconds = static_cast<double>(frames) / rate;
|
||||||
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
|
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
|
||||||
if (nh.hit) {
|
if (nh.hit) {
|
||||||
@@ -1697,6 +1688,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
dragStartY_ = y;
|
dragStartY_ = y;
|
||||||
dragStartEnv_ = env;
|
dragStartEnv_ = env;
|
||||||
dragSampleFrames_ = frames;
|
dragSampleFrames_ = frames;
|
||||||
|
dragStartFrame_ = startFrame;
|
||||||
dragStartMap_ = map_;
|
dragStartMap_ = map_;
|
||||||
return; // node moves once the cursor drags
|
return; // node moves once the cursor drags
|
||||||
}
|
}
|
||||||
@@ -1941,7 +1933,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
|||||||
totalSeconds, envClampBounds(), dx, dy);
|
totalSeconds, envClampBounds(), dx, dy);
|
||||||
const int zi = ensureSampleZone();
|
const int zi = ensureSampleZone();
|
||||||
if (zi >= 0) {
|
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;
|
selectedZone_ = zi;
|
||||||
}
|
}
|
||||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||||
|
|||||||
@@ -281,12 +281,16 @@ private:
|
|||||||
|
|
||||||
// PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the
|
// PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the
|
||||||
// Trigger fade fractions from the source-frame fades over the played span.
|
// 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
|
// 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.
|
// 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.
|
// 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
|
// 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).
|
// 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;
|
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||||
SetupMarkers dragStartMarkers_;
|
SetupMarkers dragStartMarkers_;
|
||||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
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
|
// 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
|
// from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the
|
||||||
|
|||||||
@@ -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.
|
// 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
|
// 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.
|
// 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::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
|
||||||
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
|
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
|
||||||
if (onSeq != 0 && onSeq != previewOnConsumed_) {
|
if (onSeq != 0 && onSeq != previewOnConsumed_) {
|
||||||
previewOnConsumed_ = onSeq;
|
previewOnConsumed_ = onSeq;
|
||||||
|
if (inst) {
|
||||||
const int vel = static_cast<int>((on >> 8) & 0xFF);
|
const int vel = static_cast<int>((on >> 8) & 0xFF);
|
||||||
const int note = static_cast<int>(on & 0xFF);
|
const int note = static_cast<int>(on & 0xFF);
|
||||||
if (vel > 0) inst->engine.noteOn(note, vel);
|
if (vel > 0) inst->engine.noteOn(note, vel);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inst) {
|
||||||
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
|
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
|
||||||
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
||||||
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Standalone tests for reasampler::vst::trigger_seam — no VST3, no REAPER, no framework.
|
||||||
|
// Same fast assert loop as the sibling pure tests.
|
||||||
|
//
|
||||||
|
// Covers: triggerPlayLength (zero play length, startFrame set, startFrame past frameCount,
|
||||||
|
// rounding); framesToFadeFraction (zero play length, basic ratio); fadeFractionToFrames
|
||||||
|
// (zero play length, rounding); round-trip fidelity; the Finding 1 regression (start-point
|
||||||
|
// set — the case that was broken before this module existed).
|
||||||
|
|
||||||
|
#include "../src/vst/trigger_seam.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
using namespace reasampler::vst;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// --- triggerPlayLength --------------------------------------------------------
|
||||||
|
|
||||||
|
static void testPlayLengthNoStartPoint() {
|
||||||
|
// startFrame == 0 (no start marker): postStart = frameCount.
|
||||||
|
// 1000 frames, lengthFraction 1.0 -> 1000.
|
||||||
|
CHECK(triggerPlayLength(1.0, 1000, 0) == 1000);
|
||||||
|
// 1000 frames, lengthFraction 0.5 -> round(500.0) = 500.
|
||||||
|
CHECK(triggerPlayLength(0.5, 1000, 0) == 500);
|
||||||
|
// 1000 frames, lengthFraction 0.333 -> round(333.0) = 333.
|
||||||
|
CHECK(triggerPlayLength(0.333, 1000, 0) == 333);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlayLengthWithStartPoint() {
|
||||||
|
// The Finding 1 regression: startFrame set, play length must be shorter.
|
||||||
|
// frameCount=1000, startFrame=200 -> postStart=800.
|
||||||
|
// lengthFraction 1.0 -> 800 (NOT 1000 as the pre-fix code produced).
|
||||||
|
CHECK(triggerPlayLength(1.0, 1000, 200) == 800);
|
||||||
|
// lengthFraction 0.5 -> round(400.0) = 400.
|
||||||
|
CHECK(triggerPlayLength(0.5, 1000, 200) == 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlayLengthZeroFrameCount() {
|
||||||
|
// No decoded audio -> 0.
|
||||||
|
CHECK(triggerPlayLength(1.0, 0, 0) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlayLengthStartFramePastEnd() {
|
||||||
|
// startFrame >= frameCount -> postStart clamped to 0 -> play length 0.
|
||||||
|
CHECK(triggerPlayLength(1.0, 500, 500) == 0);
|
||||||
|
CHECK(triggerPlayLength(1.0, 500, 600) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlayLengthRounding() {
|
||||||
|
// round(0.5) = 1 (round half up via +0.5 truncation: 0.5 + 0.5 = 1.0 -> 1).
|
||||||
|
CHECK(triggerPlayLength(0.5, 1, 0) == 1);
|
||||||
|
// round(lengthFraction * 3): 0.4 * 3 = 1.2 -> 1.
|
||||||
|
CHECK(triggerPlayLength(0.4, 3, 0) == 1);
|
||||||
|
// 0.6 * 3 = 1.8 -> 2.
|
||||||
|
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- framesToFadeFraction -----------------------------------------------------
|
||||||
|
|
||||||
|
static void testFramesToFadeFractionBasic() {
|
||||||
|
// 100 frames fade over 1000 play length -> 0.1.
|
||||||
|
const double frac = framesToFadeFraction(100, 1000);
|
||||||
|
CHECK(frac > 0.0999 && frac < 0.1001);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFramesToFadeFractionZeroPlayLength() {
|
||||||
|
// Degenerate: zero play length -> 0.0 (no division by zero).
|
||||||
|
CHECK(framesToFadeFraction(100, 0) == 0.0);
|
||||||
|
CHECK(framesToFadeFraction(0, 0) == 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFramesToFadeFractionFullSpan() {
|
||||||
|
// fadeFrames == playLength -> fraction 1.0.
|
||||||
|
const double frac = framesToFadeFraction(500, 500);
|
||||||
|
CHECK(frac > 0.9999 && frac < 1.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fadeFractionToFrames -----------------------------------------------------
|
||||||
|
|
||||||
|
static void testFadeFractionToFramesBasic() {
|
||||||
|
// 0.1 of 1000 play length -> round(100.0) = 100.
|
||||||
|
CHECK(fadeFractionToFrames(0.1, 1000) == 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFadeFractionToFramesZeroPlayLength() {
|
||||||
|
// Degenerate: play length 0 -> 0 frames.
|
||||||
|
CHECK(fadeFractionToFrames(0.5, 0) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFadeFractionToFramesRounding() {
|
||||||
|
// 0.333... of 3 -> round(1.0) = 1.
|
||||||
|
CHECK(fadeFractionToFrames(1.0 / 3.0, 3) == 1);
|
||||||
|
// 0.5 of 3 -> round(1.5) = 2.
|
||||||
|
CHECK(fadeFractionToFrames(0.5, 3) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Round-trip ---------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testRoundTripNoStartPoint() {
|
||||||
|
// Pack then unpack: fadeInFrames should survive (within 1 frame of rounding).
|
||||||
|
// frameCount=44100, startFrame=0, lengthFraction=1.0 -> playLength=44100.
|
||||||
|
// fadeInFrames = 2205 (5% of 44100).
|
||||||
|
const std::int64_t fadeIn = 2205;
|
||||||
|
const std::int64_t playLen = triggerPlayLength(1.0, 44100, 0);
|
||||||
|
const double frac = framesToFadeFraction(fadeIn, playLen);
|
||||||
|
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
|
||||||
|
// Should be exact (2205 / 44100 * 44100 = 2205.0).
|
||||||
|
CHECK(recovered == fadeIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripWithStartPoint() {
|
||||||
|
// The Finding 1 case: startFrame set. frameCount=44100, startFrame=8820 (20%).
|
||||||
|
// postStart=35280, lengthFraction=1.0 -> playLength=35280.
|
||||||
|
// fadeInFrames = 1764 (5% of 35280).
|
||||||
|
const std::int64_t frameCount = 44100;
|
||||||
|
const std::int64_t startFrame = 8820;
|
||||||
|
const std::int64_t fadeIn = 1764;
|
||||||
|
const std::int64_t playLen = triggerPlayLength(1.0, frameCount, startFrame);
|
||||||
|
CHECK(playLen == 35280);
|
||||||
|
const double frac = framesToFadeFraction(fadeIn, playLen);
|
||||||
|
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
|
||||||
|
CHECK(recovered == fadeIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripFadeGreaterThanSpan() {
|
||||||
|
// fadeFrames > playLength -> fraction > 1 (returned unclamped; the overlay clamps at draw).
|
||||||
|
// The shell is responsible for clamping before writing AmpEnvelope.
|
||||||
|
const std::int64_t playLen = 100;
|
||||||
|
const std::int64_t fadeIn = 150;
|
||||||
|
const double frac = framesToFadeFraction(fadeIn, playLen);
|
||||||
|
CHECK(frac > 1.0); // intentionally unclamped from this module's perspective
|
||||||
|
// The round-trip still recovers the original fade, so the shell can clamp after.
|
||||||
|
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
|
||||||
|
CHECK(recovered == fadeIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testPlayLengthNoStartPoint();
|
||||||
|
testPlayLengthWithStartPoint();
|
||||||
|
testPlayLengthZeroFrameCount();
|
||||||
|
testPlayLengthStartFramePastEnd();
|
||||||
|
testPlayLengthRounding();
|
||||||
|
|
||||||
|
testFramesToFadeFractionBasic();
|
||||||
|
testFramesToFadeFractionZeroPlayLength();
|
||||||
|
testFramesToFadeFractionFullSpan();
|
||||||
|
|
||||||
|
testFadeFractionToFramesBasic();
|
||||||
|
testFadeFractionToFramesZeroPlayLength();
|
||||||
|
testFadeFractionToFramesRounding();
|
||||||
|
|
||||||
|
testRoundTripNoStartPoint();
|
||||||
|
testRoundTripWithStartPoint();
|
||||||
|
testRoundTripFadeGreaterThanSpan();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("trigger_seam: all tests passed\n");
|
||||||
|
else std::printf("trigger_seam: %d FAILED\n", g_fail);
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user