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
+14 -1
View File
@@ -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})
+26 -33
View File
@@ -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
+7 -2
View File
@@ -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
+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. // 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;
const int vel = static_cast<int>((on >> 8) & 0xFF); if (inst) {
const int note = static_cast<int>(on & 0xFF); const int vel = static_cast<int>((on >> 8) & 0xFF);
if (vel > 0) inst->engine.noteOn(note, vel); 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::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_) {
+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
+161
View File
@@ -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;
}