From 8a7dc4b1c6bf59bc156215898f19715dc65a6cbc Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 14:37:05 -0400 Subject: [PATCH] fix(trigger-seam): thread startFrame into both envelope converters; extract pure triggerPlayLength module + tests --- CMakeLists.txt | 15 ++- src/vst/reasampler_editor.cpp | 59 +++++------ src/vst/reasampler_editor.h | 9 +- src/vst/reasampler_processor.cpp | 14 ++- src/vst/trigger_seam.cpp | 27 ++++++ src/vst/trigger_seam.h | 51 ++++++++++ tests/test_trigger_seam.cpp | 161 +++++++++++++++++++++++++++++++ 7 files changed, 296 insertions(+), 40 deletions(-) create mode 100644 src/vst/trigger_seam.cpp create mode 100644 src/vst/trigger_seam.h create mode 100644 tests/test_trigger_seam.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a113aa7..d4c6f95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -762,6 +762,13 @@ add_library(param_slider STATIC src/vst/param_slider.cpp) target_include_directories(param_slider PUBLIC src/vst) 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 . 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: 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. @@ -835,6 +842,12 @@ add_executable(param_slider_tests tests/test_param_slider.cpp) target_link_libraries(param_slider_tests PRIVATE param_slider) 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 # fade/%-length) at the waveform time base. Links ONLY envelope_overlay (+ its editor_geometry # 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 sample_map capture_paths embed_strip app_version capture_browser keyboard_strip 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; # 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}) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 3a3033f..77507c3 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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(postStart); - return static_cast(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(play.trigger.fadeInFrames) / static_cast(playLen); - env.fadeOutFraction = static_cast(play.trigger.fadeOutFrames) / static_cast(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(env.fadeInFraction * static_cast(playLen) + 0.5); - play.trigger.fadeOutFrames = - static_cast(env.fadeOutFraction * static_cast(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(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 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(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(zi)].play); + unpackEnvelope(edited, frames, dragStartFrame_, + map_.zones[static_cast(zi)].play); selectedZone_ = zi; } invalidate(); // live feedback; commit on WM_LBUTTONUP diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 9d86f10..329986f 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -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 diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 93c82e1..4fe5838 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -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(on >> 16); if (onSeq != 0 && onSeq != previewOnConsumed_) { previewOnConsumed_ = onSeq; - const int vel = static_cast((on >> 8) & 0xFF); - const int note = static_cast(on & 0xFF); - if (vel > 0) inst->engine.noteOn(note, vel); + if (inst) { + const int vel = static_cast((on >> 8) & 0xFF); + const int note = static_cast(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(off >> 16); if (offSeq != 0 && offSeq != previewOffConsumed_) { diff --git a/src/vst/trigger_seam.cpp b/src/vst/trigger_seam.cpp new file mode 100644 index 0000000..0ee61a4 --- /dev/null +++ b/src/vst/trigger_seam.cpp @@ -0,0 +1,27 @@ +// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h). + +#include "trigger_seam.h" + +#include + +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(lengthFraction * static_cast(postStart) + 0.5); +} + +double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) { + if (playLength <= 0) return 0.0; + return static_cast(fadeFrames) / static_cast(playLength); +} + +std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) { + if (playLength <= 0) return 0; + return static_cast(fadeFraction * static_cast(playLength) + 0.5); +} + +} // namespace reasampler::vst diff --git a/src/vst/trigger_seam.h b/src/vst/trigger_seam.h new file mode 100644 index 0000000..d7ce3c3 --- /dev/null +++ b/src/vst/trigger_seam.h @@ -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 + +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 diff --git a/tests/test_trigger_seam.cpp b/tests/test_trigger_seam.cpp new file mode 100644 index 0000000..58cbcb5 --- /dev/null +++ b/tests/test_trigger_seam.cpp @@ -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 + +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; +}