diff --git a/cmake/reasampler_targets.cmake b/cmake/reasampler_targets.cmake index dfffd1a..561301a 100644 --- a/cmake/reasampler_targets.cmake +++ b/cmake/reasampler_targets.cmake @@ -15,7 +15,7 @@ function(reasampler_pure_library name) # A default-less switch missing an enumerator: MSVC's C4062 is off by its /W1 default; # GCC/Clang's -Wswitch is on by default but only warns without -Werror, and this repo # sets no -Wall/-Werror/-W4/-WX anywhere. Promoted to an error only here, on our own - # pure libraries, so a deliberately default-less switch (e.g. isLiveDeckParam, + # pure libraries, so a deliberately default-less switch (e.g. deckParamCommit, # deck_groups.cpp) is a compile error on every toolchain. NOT C4061 (fires even with # a default: present) — that would light up every defensive switch in the tree. if(MSVC) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index d2df569..f819a5c 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -171,9 +171,10 @@ Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other must live compute, latching the parameters at note on is not acceptable. long term these will be automatable parameters."* It rejects the precedent, not one instance of it. -- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and - `liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and - why each exclusion is excluded — see there rather than restating the list here. +- **How a control reaches the audio is ONE decision, recorded in ONE place** — `deckParamCommit` + and `liveCommitFor` (`ui/deck_groups`), a THREE-state classification (`Live` / + `NoteOnLatched` / `Reload`) whose header is THE home for where each control sits and why — + see there rather than restating the list here. - **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one block the shell owns per instance. The member-ordering constraint that enforces it, and why, are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice @@ -316,7 +317,7 @@ anything for a trigger shape. - `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`. - `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping. -- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v15), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. +- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. - `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home. - `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer. @@ -354,7 +355,7 @@ anything for a trigger shape. drag the bank model and the WAV codec in behind it. The shell keeps only the controls the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them. -- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. +- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 5a9075a..6c3246a 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -67,10 +67,12 @@ decision about what the render made obsolete. - **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are offset from each other whenever the note and the capture window do not start together. `bake_plan.h` says which field is in which; do not read them as one clock. -- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch - offset makes the read head take longer to cross its span, so the window is scaled by the - deepest downward offset the voice can reach — a shallower excursion leaves trailing silence - in the file. Both the Trigger span and the Gate exhaustion length take it. +- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that + slows the read makes the head take longer to cross its span, so the window is scaled by the + slowest read the voice can reach — a shallower excursion leaves trailing silence in the file. + Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed + alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length + take the product, and the Gate-with-loop branch takes neither. - **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves are live, so the velocity is a property of the sound being printed and not a detail of the render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window). diff --git a/src/core/instrument/bake/bake_plan.cpp b/src/core/instrument/bake/bake_plan.cpp index d991ea5..04820be 100644 --- a/src/core/instrument/bake/bake_plan.cpp +++ b/src/core/instrument/bake/bake_plan.cpp @@ -6,6 +6,7 @@ #include #include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold) +#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound) #include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length) #include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula) @@ -28,22 +29,36 @@ bool toFrames(double seconds, int rate, std::int64_t& out) { return true; } -// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only -// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset -// stretches how long the source takes to play out. Preserve decouples the two, and a Gate -// release is ticked per output frame, so neither is affected. -double downwardSemitones(const PlayParams& play, int velocity) { - if (play.pitchEngine != PitchEngine::Varispeed) return 0.0; - double down = (std::min)(0.0, kVelocityPitchRangeSemitones * - play.pitchVelocityCurve.eval(velocity)); - if (play.pitchEnv.enabled) { - // A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points; - // the staged AHD only ever travels between 0 and the peak. - down += play.pitchSpline.mode == EnvMode::Spline - ? -std::fabs(play.pitchEnv.peakSemitones) - : (std::min)(0.0, play.pitchEnv.peakSemitones); +// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the +// factor a source span is scaled by to bound how long it takes to play out. Two terms: +// +// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve +// feeds the stretcher at it, so either way the source is consumed at that many frames per +// output frame. Taken through the engine's clamp, because that is the value Voice::start +// actually plays. +// +// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head +// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read +// rate alone, which is the only sense in which the two are decoupled there. +// +// A Gate release is ticked per output frame, so neither term touches it. +double playbackStretch(const PlayParams& play, int velocity) { + double down = 0.0; + if (play.pitchEngine == PitchEngine::Varispeed) { + down = (std::min)(0.0, kVelocityPitchRangeSemitones * + play.pitchVelocityCurve.eval(velocity)); + // Taken as a bound rather than exactly, like the velocity term beside it: an upward + // offset only makes the read faster, and every term in this sum is a floor. + down += (std::min)(0.0, play.pitchOffsetSemitones); + if (play.pitchEnv.enabled) { + // A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points; + // the staged AHD only ever travels between 0 and the peak. + down += play.pitchSpline.mode == EnvMode::Spline + ? -std::fabs(play.pitchEnv.peakSemitones) + : (std::min)(0.0, play.pitchEnv.peakSemitones); + } } - return down; + return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate); } // Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top) @@ -78,8 +93,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate, const double rate = static_cast(renderSampleRate); const auto frameCount = static_cast(dialed.frames.size()); const std::int64_t start = effectiveStart(dialed); - const double stretch = - std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0); + const double stretch = playbackStretch(dialed.play, p.velocity.value()); const double releaseSeconds = static_cast(dialed.play.adsr.releaseFrames) / rate; double endOffsetSeconds = 0.0; diff --git a/src/core/instrument/bake/bake_plan.h b/src/core/instrument/bake/bake_plan.h index b5ea3a1..c8def2c 100644 --- a/src/core/instrument/bake/bake_plan.h +++ b/src/core/instrument/bake/bake_plan.h @@ -32,7 +32,8 @@ bool bakeWindowNeedsHold(const SampleData& dialed); // the bake renders at, which is what the engine's frame counts are consumed against): // // Trigger — the note IS the play span (note-off is ignored anyway), stretched by the -// deepest downward Varispeed offset. +// slowest read the dialed voice can reach: Rate under BOTH engines, plus the +// deepest downward pitch offset under Varispeed. // Gate, loop — `hold` is the note length; the end offset is the release. // Gate, no loop— the read head runs off the source and frees the voice whatever the gate is // doing, so the note is the whole post-start span, stretched the same way. @@ -44,7 +45,7 @@ bool bakeWindowNeedsHold(const SampleData& dialed); // Every case is padded by the voice's terminal declick ramp (kDeclickFrames): trailing // silence is free, and closing the window on the frame the ramp starts is a hard cut. // `hold` is read only in the Gate-with-loop case; `velocity` is the velocity the note fires -// at, and it feeds the Varispeed stretch as well as the render. +// at, and it feeds the Varispeed half of that stretch as well as the render. // // Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` — // meets the tempo in resolveNote, with the rest of the program's beat-denominated fields. diff --git a/src/core/instrument/engine/envelopes.h b/src/core/instrument/engine/envelopes.h index 7818a96..53baef1 100644 --- a/src/core/instrument/engine/envelopes.h +++ b/src/core/instrument/engine/envelopes.h @@ -421,7 +421,12 @@ public: // Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice // that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete // toggle travelling by reload, so the caller's copy of it is deliberately ignored. - void snapLive(const PitchEnvParams& params) { + // + // Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is + // an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a + // live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact. + void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) { + span_ = spanFrames > 0 ? spanFrames : 0; params_.peakSemitones = params.peakSemitones; params_.shape = params.shape; fit_ = fitAhd(span_, params_.shape); @@ -430,9 +435,11 @@ public: // Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized // position within whichever leg the envelope is in, and absorb the depth step (peak is a - // level, not a duration). - void applyLive(const PitchEnvParams& params) { + // level, not a duration). A moved span re-fits under the same rule, so a live Pitch move + // reshapes this envelope continuously instead of leaving it on the note-on read rate. + void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) { const double before = offsetAt(); + span_ = spanFrames > 0 ? spanFrames : 0; const AhdSpan next = fitAhd(span_, params.shape); pos_ = holdPhase(fit_, next); params_.peakSemitones = params.peakSemitones; diff --git a/src/core/instrument/engine/live_params.cpp b/src/core/instrument/engine/live_params.cpp index c1412a8..2153782 100644 --- a/src/core/instrument/engine/live_params.cpp +++ b/src/core/instrument/engine/live_params.cpp @@ -16,6 +16,8 @@ LiveValues foldLive(const PlayParams& params) { v.adsr = params.adsr; v.ampAhd = params.trigAhd; v.pitchEnv = params.pitchEnv; + v.playRate = params.playRate; + v.pitchOffsetSemitones = params.pitchOffsetSemitones; return v; } diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index f2e2da3..7874011 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -44,6 +44,14 @@ struct LiveValues { AdsrParams adsr{}; AhdParams ampAhd{}; PitchEnvParams pitchEnv{}; + // The block's THIRD commit class, and the reason this comment is here rather than at the + // predicate: playRate is published like any live control but read ONLY at note-on, by + // Voice::start via VoiceEngine::startVoice — never by applyLive on a sounding voice. A live + // rate would mean re-folding an already-resolved sustain loop and re-mapping a contour + // mid-note, both of which are note-on folds. pitchOffsetSemitones has no such tie and is + // ordinarily live. + double playRate = 1.0; + double pitchOffsetSemitones = 0.0; }; // The seqlock copies the block as raw bytes, which is only defensible for a plain value type. diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h index c2605f7..610b93b 100644 --- a/src/core/instrument/engine/period_detect.h +++ b/src/core/instrument/engine/period_detect.h @@ -107,7 +107,7 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) // It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the // loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span // this cache was derived from — whereas play mode's exclusion from live delivery is a listed, -// reversible decision (deck_groups' isLiveDeckParam). Keying a load-time cache on it would work +// reversible decision (deck_groups' deckParamCommit). Keying a load-time cache on it would work // today and silently serve a stale period the day that decision is revisited. // // The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 6fd2af8..8851210 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -158,6 +158,15 @@ struct PlayParams { TriggerParams trigger; // Trigger play span AhdParams trigAhd; // Trigger amp PitchEngine pitchEngine = PitchEngine::Varispeed; + // Playback RATE, as source frames consumed per output frame. Under Varispeed it is one more + // factor of the read increment, so it moves pitch and duration together; under Preserve it + // drives duration alone and the shifter holds the pitch. Latched at note-on either way (the + // loop fold and the contour scale it composes with are both note-on folds), and clamped by + // the stretcher's own clampStretchRate — never here. 1.0 is the bare engine, bit for bit. + double playRate = 1.0; + // A baseline pitch offset in semitones, folded into the note's ratio beside key-tracking and + // the velocity->pitch transpose. Live on a sounding voice under both engines. + double pitchOffsetSemitones = 0.0; PitchEnvParams pitchEnv; // Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0 // by default, so it transposes nothing until a curve is drawn. Folded into the voice's diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 7da8710..73d12f8 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -53,16 +53,29 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick sample_ = &sample; const PlayParams& p = sample.play; - // Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather - // than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed - // read-rate bias and Preserve shift amount both derive from it below). + // Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ rather than + // costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed read-rate + // bias and Preserve shift amount both derive from it below). velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity); - baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_; + pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones); playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; - // Clamped once here so the read head's increment and the feed cursor's debt accumulate the - // SAME value — they must stay exactly one window apart for the note's whole life. + // THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for + // a rate this moves. Clamped once here so the read head's increment and the feed cursor's + // debt accumulate the SAME value: they must stay exactly one window apart for the note's + // whole life. stretchRate_ = instrument::engine::clampStretchRate(stretchRate); + // Keyed on the read path this note will ACTUALLY take, which is not the same question as + // the stored engine: advanceFrame runs the Preserve branch only while the shifters are + // configured, and a Preserve voice whose shifters were never sized falls back to the + // varispeed read. Rate has to reach the increment there too, or that fallback would ignore + // the control outright — the predicate is spelled the same way advanceFrame spells it. + preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); + rateRatio_ = preserveRead_ ? 1.0 : stretchRate_; + recomputeBaseRatio(); + // pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at + // Pitch 0 it is a division by exactly 1.0. + pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_; // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top) // rather than starting a voice already off the end. @@ -119,23 +132,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick if (playLen > postStart) playLen = postStart; playEnd_ = start + playLen; trigSpan = playLen; - ampAhd_.configure(playLen, p.trigAhd); + ampAhd_.configure(playLen, rateFittedAhd(p.trigAhd)); } // The pitch AHD's Hold fraction is taken against the whole playable span, so its three - // stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count - // and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes - // baseRatio_ source frames per output frame — needs the span converted, or a transposed - // note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source - // rate, so its two domains already coincide. - // Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x - // envFactor — a deep pitch envelope makes this a first-order approximation, not exact. - // Strictly better than the un-converted source-frame span it replaced. - const double pitchSpan = - (pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0)) - ? static_cast(postStart) - : static_cast(postStart) / baseRatio_; - pitchEnv_.configure(static_cast(pitchSpan + 0.5), p.pitchEnv); + // stages lay 1:1 over the waveform from the start point. The source->output conversion, and + // why it is only first-order, are pitchEnvSpanFrames' own (voice.h). + pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv); pitchEnv_.noteOn(); // A restart lands every live glide back on the new note's own values, at a step derived @@ -169,7 +172,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick filterEnv_.configure(p.filter.env); filterEnv_.noteOn(); } else { - filterAhd_.configure(trigSpan, p.filter.trigEnv); + filterAhd_.configure(trigSpan, rateFittedAhd(p.filter.trigEnv)); } filter_.reset(); updateFilterCutoffBase(note); @@ -263,15 +266,26 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { // A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a // flag: a voice that has rendered nothing has no phase to hold and nothing to be // continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h). + // + // live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class, + // delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The + // latched stretchRate_ is what stageFitRate carries into every conversion below, so a + // stage-time move mid-note lands in this note's own rate domain rather than resetting it. const bool gate = (playMode_ == PlayMode::Gate); + // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_ + // up as one more factor of next frame's read increment, Preserve as the shifter's transpose. + // Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate + // both of them are fitted against — a stale offset here would fit them to the previous move. + pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones); + recomputeBaseRatio(); if (snap) { if (gate) env_.snapLive(live.adsr); - else ampAhd_.snapLive(live.ampAhd); - pitchEnv_.snapLive(live.pitchEnv); + else ampAhd_.snapLive(rateFittedAhd(live.ampAhd)); + pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv); } else { if (gate) env_.applyLive(live.adsr); - else ampAhd_.applyLive(sourceOffset(), live.ampAhd); - pitchEnv_.applyLive(live.pitchEnv); + else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd)); + pitchEnv_.applyLive(pitchEnvSpanFrames(), live.pitchEnv); } // The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but // pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_, @@ -283,10 +297,10 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { if (snap) { if (gate) filterEnv_.snapLive(live.filterEnv); - else filterAhd_.snapLive(live.filterAhd); + else filterAhd_.snapLive(rateFittedAhd(live.filterAhd)); } else { if (gate) filterEnv_.applyLive(live.filterEnv); - else filterAhd_.applyLive(sourceOffset(), live.filterAhd); + else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd)); } filterCutoffNorm_ = static_cast(live.filterSettings.cutoffNorm); filterKeyTrack_ = live.filterKeyTrack; @@ -324,12 +338,13 @@ void Voice::retune(int note) { // legato phrase is one gesture, one strike (classic mono-synth behavior). if (!active_ || sample_ == nullptr) return; note_ = note; - // Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the - // baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the - // first note's domain — consistent with "touch nothing else," but the drift lives here. + // Changes baseRatio_ without re-converting pitchEnv_'s already-configured span + // (pitchEnvSpanFrames, whose base rate this deliberately does not move), so a slide leaves + // that envelope on the first note's domain — consistent with "touch nothing else," but the + // drift lives here. // The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ — - // one gesture, one strike. - baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_; + // one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved. + recomputeBaseRatio(); // Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it // too. The velocity offset deliberately stays the first note's, matching velocityGain_. if (filterOn_) updateFilterCutoffBase(note); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 1349f9d..e104eb8 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -50,12 +50,17 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) { return std::pow(2.0, semis / 12.0); } -// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which -// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields -// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing. +// 2^(semitones/12). Exactly 1.0 at zero — and it SKIPS the pow there, so an unset offset +// transposes nothing and costs nothing. +inline double semitoneRatio(double semitones) { + return (semitones == 0.0) ? 1.0 : std::pow(2.0, semitones / 12.0); +} + +// The velocity->pitch transpose, which the voice folds into baseRatio_ once at note-on. A curve +// flat at 0 — the default — yields EXACTLY 1.0 at every velocity. inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) { - const double semis = curve.eval(static_cast(velocity)) * kVelocityPitchRangeSemitones; - return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0); + return semitoneRatio(curve.eval(static_cast(velocity)) * + kVelocityPitchRangeSemitones); } // One octave expressed in the cutoff control's normalized domain, read out of the filter @@ -110,11 +115,15 @@ public: // the difference-seeded declick compensation on the first frame after the restart (see // kDeclickDecay above). A fresh start never declicks. // - // `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame, - // clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an - // argument, not a member set separately) because the loop fold and the contour scale it - // composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the - // read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit. + // `stretchRate` is the playback rate — source frames consumed per output frame, clamped to + // [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an argument, not + // a member set separately) because the loop fold and the contour scale it composes with are + // both note-on folds. Under Preserve it is the stretcher's feed rate and duration alone moves; + // under Varispeed it folds into the read increment beside key-tracking, so pitch moves with + // it. 1.0 is the bare engine, bit for bit, in both. Defaulted so a caller with no live block + // to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value — + // sample.play.playRate is NOT read here, because the published block outranks the snapshot's + // possibly-stale copy of it. void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false, double stretchRate = 1.0); @@ -184,6 +193,65 @@ public: } private: + // THE fold of every pitch factor that is constant for the note into one number, so + // advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has + // always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset, + // and the Rate ratio — which start() zeroes out of this product when the note is running the + // Preserve read, since Rate feeds stretch_ (duration) there and must never reach the + // shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame. + void recomputeBaseRatio() { + if (sample_ == nullptr) return; + baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) * + velPitchRatio_ * pitchOffsetRatio_ * rateRatio_; + } + + // The rate the read head consumes SOURCE at, counting only the factors whose stage-time + // coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset + // transposes inside the shifter and never touches the read. Under Varispeed both Rate and + // Pitch are factors of the read increment and both are compensated: they are two views of one + // multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the + // velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped + // sounds, and compensating them would move every note off the root. + double stageFitRate() const { + return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_; + } + + // A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the + // sustain-less envelopes are evaluated in (sourceOffset()). The read stretches the source + // span those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate — + // multiplying by the read rate is exactly that conversion. A fit of exactly 1.0 (Rate 100 %, + // Pitch 0 st) returns the argument untouched, which is what keeps the unity render + // bit-identical. + AhdParams rateFittedAhd(const AhdParams& a) const { + const double fit = stageFitRate(); + if (fit == 1.0) return a; + AhdParams out = a; + out.attackFrames = + static_cast(static_cast(a.attackFrames) * fit + 0.5); + out.decayFrames = + static_cast(static_cast(a.decayFrames) * fit + 0.5); + return out; + } + + // The pitch AHD's span. That envelope counts OUTPUT frames while its Hold fraction is taken + // against the playable SOURCE span, so the span converts by the rate the read head consumes + // source at. Divides by that alone though the Varispeed read rate is really baseRatio_ x + // envFactor: a deep pitch envelope makes it a first-order approximation, not exact. + // + // Shared by note-on and every live re-application, so a live Pitch move re-fits the envelope + // rather than leaving it on the offset the note started at. Only that live factor is + // re-read — pitchSpanBaseRate_ has it divided out — which is what leaves a legato retune's + // documented drift (retune) exactly where it was. + std::int64_t pitchEnvSpanFrames() const { + if (sample_ == nullptr) return 0; + const double postStart = static_cast( + static_cast(sample_->frames.size()) - startFrame_); + const double readRate = + preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_; + const double span = (readRate > 0.0) ? postStart / readRate : postStart; + return static_cast(span + 0.5); + } + // The read head as a fraction of the whole sample — the domain every spline EG is a pure // function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on // its opening value. @@ -192,9 +260,9 @@ private: // This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read // at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once // per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD - // is evaluated at the source offset (readPos - startFrame) — see the `ratio_ = stretchRate_` - // note below for what that means for Preserve's stage-time/rate coupling. Sets - // amplitudeDone_ on finish so advanceFrame frees the voice. + // is evaluated at the source offset (readPos - startFrame), which is why its stage frames are + // fitted to the read rate at configure time (rateFittedAhd). Sets amplitudeDone_ on finish so + // advanceFrame frees the voice. double tickAmplitude() { double amp; // playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it — @@ -532,15 +600,8 @@ private: // Everything downstream of it (the loop wrap, the Trigger span, the spline phase) // therefore stays a source-frame fact and scales by construction. // - // Consequence (§2.4 of instrument-control-surface.md is explicit that staged - // envelopes' stage times are wall-clock and do NOT scale with rate): Trigger's amp - // AHD and filter AHD are both evaluated at sourceOffset() = readPos_ - startFrame_ - // (tickAmplitude/tickFilterCutoff above), which now advances at stretchRate_ instead - // of always 1.0 — so those two envelopes will scale with a future non-unity Rate. - // This is NEW here: Preserve's ratio_ was pinned at 1.0 before this track, so those - // stage times were exact wall-clock. It is latent (nothing publishes a non-unity - // rate yet) and owned by the track that adds the Rate control, not this one — Gate's - // AHDSR (env_.tick(), per-output-frame) and every spline contour are unaffected. + // The two sustain-less envelopes are evaluated at sourceOffset(), which advances at + // this rate — rateFittedAhd is what keeps their stage times wall-clock anyway. ratio_ = stretchRate_; } else { // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the @@ -645,8 +706,18 @@ private: bool releasing_ = false; int note_ = 0; double velocityGain_ = 1.0; - double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in + double baseRatio_ = 1.0; // recomputeBaseRatio's product: every constant pitch factor double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it + double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive + double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1 + // Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters + // were never sized falls back to the varispeed one, and the two domains differ. Latched at + // note-on beside rateRatio_, which start() resolves from the same predicate. + bool preserveRead_ = false; + // baseRatio_ with the live Pitch factor divided back out, latched at note-on: what + // pitchEnvSpanFrames multiplies the CURRENT offset onto. Exact at Pitch 0 (the factor is + // exactly 1.0), which is what keeps the unity span bit-identical. + double pitchSpanBaseRate_ = 1.0; double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; diff --git a/src/core/instrument/engine/voice_engine.cpp b/src/core/instrument/engine/voice_engine.cpp index 9c106c5..2417d57 100644 --- a/src/core/instrument/engine/voice_engine.cpp +++ b/src/core/instrument/engine/voice_engine.cpp @@ -54,7 +54,11 @@ void VoiceEngine::applyLiveToActive() { void VoiceEngine::startVoice(Voice& voice, int note, int velocity) { refreshLive(); - voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); + // THE read of the note-on-latched commit class, and the only one: a published block outranks + // the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below + // never touches the rate — so a Rate move reaches the next note and no sounding one. + const double rate = haveLive_ ? live_.playRate : sample_.play.playRate; + voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate); if (haveLive_) voice.applyLive(live_, /*snap=*/true); voice.setStartOrder(nextStartOrder_++); } diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 287d616..59eaaf1 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -8,7 +8,7 @@ // own links are velocity_curve + master_gain (wire value validation), never the engine. // // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params -// payload v1..v15) must be preserved exactly. This header is the ONE home for both ladders +// payload v1..v16) must be preserved exactly. This header is the ONE home for both ladders // and every version constant; the payload half is IMPLEMENTED in params_payload. #include @@ -129,13 +129,23 @@ namespace reasampler::instrument::map { // A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record — // the same revive discipline the v13 hard-flag tails follow, and for the same reason. // -// v15 (CURRENT WRITE FORMAT) is v14 PLUS ONE byte: the master-bus limiter's enable, appended -// after the Hold division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed, +// v15 is v14 PLUS ONE byte: the master-bus limiter's enable, appended after the Hold +// division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed, // which is also the field's product default, so a project saved before the limiter existed // reopens with the limiter off and sounding identical. It carries the Hold's revive // discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte // would otherwise reset the record the Hold's own revive just preserved. // +// v16 (CURRENT WRITE FORMAT) is v15 PLUS TWO 8-byte LE doubles, appended after the limiter +// byte: the playback RATE as a ratio, then the baseline PITCH offset in semitones. A v15-or- +// older blob is a strict prefix and lifts to 1.0 / 0.0 — unity rate and no offset, which is +// what every instance before them played, so it reopens bit-identical. Both are rate-free +// values, so nothing about them is resolved against the project rate. Same revive-and-drain +// discipline as the two tails above. The two wire GUARDS deliberately differ, and +// readRateAndPitchOffset owns why: the offset is range-checked here because nothing downstream +// bounds it, while the rate is only checked for usability because its range belongs to the +// engine's own clamp. +// // The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in // shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced // them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to @@ -167,7 +177,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // The params-payload format version and its detection marker. The marker is a high sentinel // no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so // a reader detects record shape independent of the envelope version. -inline constexpr std::uint32_t kParamsPayloadVersion = 15; // v14 + the limiter enable +inline constexpr std::uint32_t kParamsPayloadVersion = 16; // v15 + Rate and the pitch offset inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u; // The first SINGLE-RECORD payload version. Everything below it is a retired zone list and @@ -203,6 +213,10 @@ inline constexpr std::uint32_t kParamsBakeHoldVersion = 14; // kParamsPayloadVersion. inline constexpr std::uint32_t kParamsLimiterVersion = 15; +// v15 + the playback rate and the baseline pitch offset; the appended pair branches on THIS, +// never on kParamsPayloadVersion. +inline constexpr std::uint32_t kParamsRateVersion = 16; + // (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to // seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter // (frames / projectRate = seconds) — the same rate the build already receives, so the diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 1aa523b..a92a85e 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -8,6 +8,7 @@ #include // std::isfinite (wire-value validation) #include // std::move +#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound) #include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation) #include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec) @@ -263,6 +264,34 @@ void readLimiterEnable(ByteReader& r, InstrumentParams& p) { p.limiterEnabled = (flag != 0); } +// Read the v16 rate + pitch-offset pair. A truncation, or either value unusable, leaves the +// neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16 +// blob means and what every instance before them played. +// +// The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather +// than merely admitted: the stretcher owns its range, so a second copy of the bounds here could +// disagree with it — but a value that only playback clamped would re-serialize out of range and +// leave the stored value disagreeing with the needle, and with the host normalization once the +// instrument reports parameters. Finiteness stays a separate test in front of it, because +// corruption is not an out-of-range value: an infinite rate degrades to the neutral, where a +// merely-too-fast one clamps to the bound. The offset gets a real range test instead, because +// nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild +// exponent there is UB on the per-sample path. +void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) { + const bool enteredOk = r.ok; + const double rate = bitsToDouble(r.u64()); + const double offset = bitsToDouble(r.u64()); + if (reviveTruncatedTail(r, enteredOk)) return; + if (std::isfinite(rate)) p.play.playRate = engine::clampStretchRate(rate); + // The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and + // the velocity->pitch curve speak (play_params.h), reached directly rather than through the + // deck's alias of it. + if (std::isfinite(offset) && offset >= -kVelocityPitchRangeSemitones && + offset <= kVelocityPitchRangeSemitones) { + p.play.pitchOffsetSemitones = offset; + } +} + // Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default, // which is what makes a v8 blob play bit-identically under the new codec. The curve reads as // bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its @@ -501,6 +530,9 @@ void putParamsPayload(std::vector& out, const InstrumentParams& p) out.push_back(static_cast(p.bakeHold.modifier())); // v15: the master-bus limiter enable. out.push_back(p.limiterEnabled ? 1 : 0); + // v16: the playback rate (a ratio) and the baseline pitch offset (semitones), both rate-free. + putLE(out, doubleToBits(pp.playRate)); + putLE(out, doubleToBits(pp.pitchOffsetSemitones)); } // Read whichever payload shape follows: the single-record shape (v8 onward, growing by @@ -556,6 +588,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) { } if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p); if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p); + if (pv >= kParamsRateVersion) readRateAndPitchOffset(r, p); // A truncated record leaves whatever parsed plus construction defaults for the rest — // the same degrade-don't-throw contract the zone ladder always had. if (!r.ok) return PayloadRead{}; diff --git a/src/core/instrument/map/params_payload.h b/src/core/instrument/map/params_payload.h index 99a291b..95c69f1 100644 --- a/src/core/instrument/map/params_payload.h +++ b/src/core/instrument/map/params_payload.h @@ -5,7 +5,7 @@ // responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay // serialize/deserializeComponentState; nothing outside the codec calls these. // -// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its +// The format ladder (payload v1..v16) is documented in component_state_io.h, which stays its // one home. EVERY wire format is FROZEN. #include diff --git a/src/core/instrument/map/play_seconds.h b/src/core/instrument/map/play_seconds.h index b91cd78..61e9dcb 100644 --- a/src/core/instrument/map/play_seconds.h +++ b/src/core/instrument/map/play_seconds.h @@ -74,6 +74,10 @@ struct PlaySeconds { TriggerParams trigger; // Trigger play span (%-length) AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction) PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve + // Rate and the baseline pitch offset are both rate-FREE (a ratio and a semitone count), so + // they carry through resolvePlay untouched; play_params.h owns what each one means. + double playRate = 1.0; + double pitchOffsetSemitones = 0.0; PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default FilterSeconds filter; // per-voice filter, off by default diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 96ceb14..417eb30 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -237,6 +237,8 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) { out.trigger = stored.trigger; // fraction, unchanged out.trigAhd = resolveAhd(stored.trigAhd); out.pitchEngine = stored.pitchEngine; + out.playRate = stored.playRate; // a ratio, rate-free + out.pitchOffsetSemitones = stored.pitchOffsetSemitones; // semitones, rate-free out.pitchEnv.enabled = stored.pitchEnv.enabled; out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape); diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 9c428ed..e1cef5a 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -80,9 +80,11 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands) # from knob_deck. Links the header-only play_seconds, NOT sample_map: PlaySeconds is all a deck # knob edits, and sample_map would drag the bank model and the WAV codec in behind it. Same for # the filter's MorphLaw — an enum, so no filter symbol is linked. +# time_stretch carries Rate's range — the stretcher's own measured bounds, aliased here rather +# than restated so the knob's ends and the engine's clamp cannot disagree. reasampler_pure_library(deck_values SOURCES deck_values.cpp - LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain) + LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain time_stretch) reasampler_test(deck_values LINK deck_values) # The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 0c32e5e..a005612 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -23,11 +23,16 @@ std::vector sampleDeckGroups(PlayMode playMode) { const bool trigger = (playMode == PlayMode::Trigger); std::vector out; { + // PITCH/RATE. The three cells make the knob row 180, which is what the group measures + // from; the caption row (caption + gap + two 48px segments) must stay under it, so the + // caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob + // row and the group grows past 192. Widening the group is not the answer if the text ever + // outgrows 80: narrow the Varisp|Presrv segments to 44 instead. DeckGroupDesc pitch; pitch.id = kGroupPitch; - pitch.captionWidth = 38; + pitch.captionWidth = 70; pitch.captionToggle = {id(DeckParam::kPitchEngine), 48}; - pitch.cellIds = {id(DeckParam::kKeyTrack)}; + pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)}; out.push_back(std::move(pitch)); } { @@ -131,7 +136,7 @@ std::vector sampleDeckGroups(PlayMode playMode) { } DeckRow deckRowFor(DeckGroupId group) { - // Every enumerator listed and no default, on the same gate isLiveDeckParam below relies on. + // Every enumerator listed and no default, on the same gate deckParamCommit below relies on. switch (group) { case kGroupPitch: case kGroupFilter: @@ -180,8 +185,12 @@ DeckParam curveParamFor(DeckParam knob) { } } -bool isLiveDeckParam(DeckParam id) { +LiveCommit deckParamCommit(DeckParam id) { switch (id) { + // The one note-on-latched control; the header owns why. + case DeckParam::kRate: + return LiveCommit::NoteOnLatched; + case DeckParam::kPitch: case DeckParam::kAttack: case DeckParam::kHold: case DeckParam::kDecay: @@ -221,7 +230,7 @@ bool isLiveDeckParam(DeckParam id) { case DeckParam::kFilterEnvReleaseCurve: case DeckParam::kFilterTrigAttackCurve: case DeckParam::kFilterTrigDecayCurve: - return true; + return LiveCommit::Live; // Listed rather than defaulted so a newly added control is a COMPILE error here on // every toolchain — /we4062 on MSVC, -Werror=switch on GCC/Clang, both set on this // library alone in cmake/reasampler_targets.cmake — instead of silently defaulting @@ -249,9 +258,9 @@ bool isLiveDeckParam(DeckParam id) { case DeckParam::kMonoTrigger: case DeckParam::kMasterGain: case DeckParam::kCount: // not a control - return false; + return LiveCommit::Reload; } - return false; // unreachable for a valid enumerator; silences a warning. + return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning. } OverlayEnv overlayEnvForRadio(int radioId) { @@ -349,17 +358,18 @@ bool deckKnobInert(DeckParam id, const DeckEnableState& state) { } } -bool liveCommitFor(LiveDragKind kind, int paramId) { +LiveCommit liveCommitFor(LiveDragKind kind, int paramId) { switch (kind) { case LiveDragKind::kDeckKnob: - return paramId >= 0 && paramId < static_cast(DeckParam::kCount) && - isLiveDeckParam(static_cast(paramId)); + return (paramId >= 0 && paramId < static_cast(DeckParam::kCount)) + ? deckParamCommit(static_cast(paramId)) + : LiveCommit::Reload; case LiveDragKind::kEnvNode: - return true; + return LiveCommit::Live; case LiveDragKind::kOther: - return false; + return LiveCommit::Reload; } - return false; + return LiveCommit::Reload; } } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 6a76cbf..c93cbb8 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -32,6 +32,8 @@ enum class DeckParam { kPitchEnvDecay, kPitchEnvDepth, // AHD pitch depth in +/- semitones kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds) + kRate, // playback rate 50..200%, linear in semitones over +/-12 + kPitch, // baseline pitch offset, +/-kPitchDepthMaxSemis, centre-expanded // Filter. The four control positions map through filter_params' own laws; the three // depths are bipolar and centred at zero. kFilterEnable, // filter on|off caption toggle @@ -131,15 +133,24 @@ std::vector sampleDeckGroups(PlayMode playMode); // ordinary knob grab. DeckParam curveParamFor(DeckParam knob); -// Whether control `id` is delivered LIVE — straight to the voices that are already sounding — -// rather than through an instrument reload. The line is drawn at continuously-valued playback -// controls, so this is a routing decision at the editor's commit site rather than a property -// of any one knob; moving a control across the line is a change here and nowhere else. +// How an edit to a control reaches the audio — THE one decision, and the home for why each +// control sits where it does. Moving a control across a line is a change here and nowhere else, +// and Γ-W4-T1 derives the host-exposed parameter set from this same predicate, so a +// misclassification here is a mis-declared parameter there. // -// THE home for why each excluded control is excluded. Three continuous controls are outside -// the live set, plus every discrete toggle and the overlay radios: -// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable) -// name a different sound rather than a different setting of one; +// Live — straight to the voices already sounding. Continuously-valued playback +// controls, and the default for anything that is a SETTING of a note rather +// than a fact about it. +// NoteOnLatched — published into the live block like a live control, but read only at +// note-on: a sounding voice keeps the value it started with, the next one +// takes the new one. NOT the reload tier — a swept knob must never trigger a +// WAV re-decode. +// Reload — a bridge read, a re-decode and a fresh engine. +// +// The exclusions from Live, each with its reason: +// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable, +// the three Staged|Spline mode toggles) name a different sound rather than a different +// setting of one; // - the three capture-anchored overrides (root, loop span, start frame) name positions in // the decoded PCM; // - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by @@ -151,18 +162,30 @@ DeckParam curveParamFor(DeckParam knob); // - the overlay radios select what the editor DRAWS and reach no parameter at all. // Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and // inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too. -bool isLiveDeckParam(DeckParam id); +// +// kRate is the one NoteOnLatched control, and the reason is a real feature rather than a +// plumbing detail: loop points and contours both scale with rate, and both are note-on folds — +// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live +// rate would mean re-folding an already-resolved loop and re-mapping a contour mid-note without +// a discontinuity. kPitch is not implicated and is ordinarily Live. +enum class LiveCommit { Live, NoteOnLatched, Reload }; +LiveCommit deckParamCommit(DeckParam id); // The editor drag kinds that can commit live, in this pure module's own vocabulary (the // shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is // testable without a host. enum class LiveDragKind { kOther, kDeckKnob, kEnvNode }; -// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids -// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither -// reaches the enum); an envelope-node drag is live in either mode, since every stage value it -// can reach — AHDSR or AHD, on any of the three envelopes — is itself live. -bool liveCommitFor(LiveDragKind kind, int paramId); +// How a drag of `kind` commits. A deck knob answers per deckParamCommit (negative ids are the +// shell's processor-side sentinels and out-of-range ids are not controls, so neither reaches the +// enum); an envelope-node drag is Live in either mode, since every stage value it can reach — +// AHDSR or AHD, on any of the three envelopes — is itself Live. +// +// Live and NoteOnLatched take the SAME route out of the editor — one publish of the live block, +// no reload, no engine rebuild. They differ only in who reads the published value, which is the +// engine's business (live_params.h), so the shell needs the distinction only to know that +// neither reloads. +LiveCommit liveCommitFor(LiveDragKind kind, int paramId); // Which envelope the waveform overlay draws and edits. Exclusive across the three envelope // decks, and kNone is a valid resting state — the editor opens there. Transient view state: diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 782ea2f..47d911e 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -23,6 +23,10 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) { case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case DeckParam::kRate: + return rateNormFromRatio(play.playRate, kRateMinRatio, kRateMaxRatio); + case DeckParam::kPitch: + return depthNormFromSemitones(play.pitchOffsetSemitones, kPitchDepthMaxSemis); case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds); case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds); case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds); @@ -109,6 +113,10 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { case DeckParam::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; + case DeckParam::kRate: + play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break; + case DeckParam::kPitch: + play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break; case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break; case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break; case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break; @@ -202,6 +210,8 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { // and resolves to null. double* deckDoubleField(DeckParam id, PlaySeconds& p) { switch (id) { + case DeckParam::kRate: return &p.playRate; + case DeckParam::kPitch: return &p.pitchOffsetSemitones; case DeckParam::kAttack: return &p.adsr.attackSeconds; case DeckParam::kHold: return &p.adsr.holdSeconds; case DeckParam::kDecay: return &p.adsr.decaySeconds; @@ -280,6 +290,10 @@ UnitCategory deckParamUnit(DeckParam id) { case DeckParam::kFilterTrigAttack: case DeckParam::kFilterTrigDecay: return UnitCategory::Milliseconds; + // Rate DISPLAYS as a percent but its unit is the semitone — that is what puts an octave + // and a fifth under Shift, which a whole-percent snap could not reach. + case DeckParam::kRate: + case DeckParam::kPitch: case DeckParam::kPitchEnvDepth: return UnitCategory::Semitones; // The filter's four tone controls read out in Hz / Q / drive depth but snap in whole @@ -325,6 +339,14 @@ double snapDeckParamNorm(DeckParam id, double norm) { case UnitCategory::Milliseconds: return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm))); case UnitCategory::Semitones: + // Rate's semitones live in the ratio domain, so its snap round-trips through the rate + // taper rather than the depth one; the other two share the depth throw. + if (id == DeckParam::kRate) { + return rateNormFromRatio( + snapRateRatioToWholeSemitone( + rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio)), + kRateMinRatio, kRateMaxRatio); + } return depthNormFromSemitones( snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)), kPitchDepthMaxSemis); diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index b27b46f..4e52258 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -8,6 +8,7 @@ #include +#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range) #include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target) #include "core/instrument/ui/deck_groups.h" // DeckParam #include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds @@ -29,6 +30,12 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones; // Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls. inline constexpr double kKeyTrackMax = 2.0; +// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the +// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the +// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers. +inline constexpr double kRateMinRatio = engine::kStretchRateMin; +inline constexpr double kRateMaxRatio = engine::kStretchRateMax; + // The normalized [0,1] a control shows: stage times through the shared time taper, levels and // fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over // their logarithmic travel. Controls backed by per-instance state rather than the parameter set diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp index 59cc49f..8e61026 100644 --- a/src/core/instrument/ui/param_taper.cpp +++ b/src/core/instrument/ui/param_taper.cpp @@ -32,6 +32,19 @@ double depthSpan(double maxSemitones) { return std::log1p(maxSemitones / kDepthOffsetSemitones); } +double rateSpanOctaves(double minRatio, double maxRatio) { + return std::log2(maxRatio / minRatio); +} + +// The norm the general formula puts unity at, DERIVED from the bounds rather than assumed to be +// centre — it is 0.5 only when minRatio * maxRatio == 1. Both maps below pin their exact-unity +// case to this one expression, so the detent is where the curve already goes and the round trip +// closes bitwise on it. Spelling it 0.5 was correct for the shipped symmetric bounds and would +// have gone non-monotone the moment they were re-measured asymmetric. +double rateUnityNorm(double minRatio, double maxRatio) { + return -std::log2(minRatio) / rateSpanOctaves(minRatio, maxRatio); +} + } // namespace double timeNormFromSeconds(double seconds) { @@ -66,6 +79,28 @@ double depthSemitonesFromNorm(double norm, double maxSemitones) { return norm > 0.5 ? mag : -mag; } +double rateNormFromRatio(double ratio, double minRatio, double maxRatio) { + if (!(maxRatio > minRatio && minRatio > 0.0)) return 0.5; // degenerate bounds: park at unity + if (!(ratio > minRatio)) return 0.0; // also catches NaN + if (ratio >= maxRatio) return 1.0; + // The unity detent is EXACT, so unity persists as unity. + if (ratio == 1.0) return rateUnityNorm(minRatio, maxRatio); + return std::log2(ratio / minRatio) / rateSpanOctaves(minRatio, maxRatio); +} + +double rateRatioFromNorm(double norm, double minRatio, double maxRatio) { + if (!(maxRatio > minRatio && minRatio > 0.0)) return 1.0; + if (!(norm > 0.0)) return minRatio; // also catches NaN + if (norm >= 1.0) return maxRatio; + if (norm == rateUnityNorm(minRatio, maxRatio)) return 1.0; + // NOT resolved onto a decimal quantum, unlike the two maps above, and the difference is + // principled rather than an omission: this control's only default is unity, which the exact + // detent case above already delivers bitwise, so a grid would buy no preimage it does not + // already have — while costing accuracy at every whole semitone, none of which is a decimal + // ratio. Left as the plain exponential, accurate to an ulp. + return minRatio * std::exp2(norm * rateSpanOctaves(minRatio, maxRatio)); +} + double snapSecondsToWholeMs(double seconds) { if (!(seconds > 0.0)) return 0.0; return std::round(seconds * 1000.0) / 1000.0; @@ -83,4 +118,12 @@ double snapExponentToWhole(double exponent) { return util::clampCurve(std::round(util::clampCurve(exponent))); } +double snapRateRatioToWholeSemitone(double ratio) { + if (!(ratio > 0.0)) return 1.0; // also catches NaN: an unusable rate snaps to unity + // exp2 of a whole number of twelfths: 0 gives exactly 1.0 and +/-12 exactly halving/doubling, + // so a snap to the detent or to either end lands on the taper's own endpoint doubles. An + // in-range input stays in range, which is why this takes no bounds. + return std::exp2(std::round(12.0 * std::log2(ratio)) / 12.0); +} + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h index cbfc703..37b5484 100644 --- a/src/core/instrument/ui/param_taper.h +++ b/src/core/instrument/ui/param_taper.h @@ -73,6 +73,23 @@ double timeSecondsFromNorm(double norm); double depthNormFromSemitones(double semitones, double maxSemitones); double depthSemitonesFromNorm(double norm, double maxSemitones); +// Playback RATE as a ratio, exponential across the travel — i.e. LINEAR IN SEMITONES, the one +// exception to the centre expansion above. Centre expansion applies to a semitone knob whose +// throw exceeds +/-12; this throw IS +/-12 (half rate to double rate), already 0.19 st per drag +// pixel, so expanding it would buy resolution nothing needs and cost the extremes. +// +// The bounds are PARAMETERS for the same reason the depth throw is: they belong to the engine's +// stretcher, which owns the measurement they came from, and a second copy here could drift from +// it. The map is monotone and hits them exactly at norm 0 and 1, so a norm in [0,1] cannot reach +// a ratio the engine's own clamp would then move — ONE clamp, at the stretcher, not two. +// Exactly 1.0 at the norm the bounds themselves put unity at — `-log2(minRatio) / span`, which +// is 0.5 only when minRatio * maxRatio == 1 — whenever they bracket it. That detent is this +// control's whole preimage obligation; see rateRatioFromNorm for why it carries no output +// quantum. Pinning it to 0.5 regardless of the bounds is the specific mistake to avoid: it makes +// the map non-monotone the moment the stretcher's measured range stops being symmetric. +double rateNormFromRatio(double ratio, double minRatio, double maxRatio); +double rateRatioFromNorm(double norm, double minRatio, double maxRatio); + // --- Shift's whole-unit snaps, in the VALUE domain ------------------------------------------- // // Stated over values rather than norms because "whole unit" means whole unit of what the control @@ -83,5 +100,9 @@ double snapSecondsToWholeMs(double seconds); double snapFractionToWholePercent(double fraction); // 1.0 == 100 % double snapSemitonesToWhole(double semitones); double snapExponentToWhole(double exponent); // clamped into curve_law's own domain +// Rate's unit is the SEMITONE even though it displays as a percent, so its whole unit is one of +// the 25 semitone steps between the bounds — which is what puts an octave and a fifth under the +// hand. Stated over the ratio because that is what the control stores. +double snapRateRatioToWholeSemitone(double ratio); } // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 1802e8c..449dac3 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -69,14 +69,16 @@ scattered `#ifdef`s in the VST shell, except the one described below). (`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`). **The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and -which route a control takes is decided once, by the pure `isLiveDeckParam` / `liveCommitFor` pair +which route a control takes is decided once, by the pure `deckParamCommit` / `liveCommitFor` pair (`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see `core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale. 1. **Full reload** — `reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap. 2. **Engine rebuild** — `rebuildVoiceEngine`: same drain-slot swap around the already-decoded `SampleData`. Voice count / mode / mono trigger. 3. **Live** — `publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free - publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. + publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. The + pure predicate splits this tier by WHO READS the published value (`Live` vs `NoteOnLatched`); + the route out of the editor is the same one either way. The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 6d623db..16c0052 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -211,6 +211,16 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const { snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; case ParamControl::kKeyTrack: snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break; + case ParamControl::kRate: { + // One decimal below 100 % only: the taper is linear in semitones, so the lower half + // spends 50 percentage points on the same twelve semitones the upper half spends + // 100 on — a whole percent is twice as coarse a step down there. + const double pct = play.playRate * 100.0; + snprintf(buf, sizeof(buf), pct < 100.0 ? "%.1f%%" : "%.0f%%", pct); + break; + } + case ParamControl::kPitch: + snprintf(buf, sizeof(buf), "%+.1fst", play.pitchOffsetSemitones); break; case ParamControl::kVoiceCount: snprintf(buf, sizeof(buf), "%d", voiceCount_); break; case ParamControl::kMasterGain: diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index 3a7bfbf..96af2d5 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -73,6 +73,8 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case ParamControl::kTrigHold: return "Hold"; case ParamControl::kTrigDecay: return "Decay"; case ParamControl::kKeyTrack: return "Key Trk"; + case ParamControl::kRate: return "Rate"; + case ParamControl::kPitch: return "Pitch"; case ParamControl::kPitchEnvAttack: return "P.Att"; case ParamControl::kPitchEnvHold: return "P.Hold"; case ParamControl::kPitchEnvDecay: return "P.Dec"; @@ -109,7 +111,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { const char* caption = ""; switch (g.id) { case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; - case kGroupPitch: caption = "PITCH"; break; + case kGroupPitch: caption = "PITCH/RATE"; break; case kGroupPitchEnv: caption = "PITCH ENV"; break; case kGroupFilter: caption = "FILTER"; break; case kGroupFilterEnv: caption = "FILTER ENV"; break; diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 7c51cad..e6b0ec4 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -190,7 +190,10 @@ bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const { const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob : kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode : LiveDragKind::kOther; - return instrument::ui::liveCommitFor(k, paramId); + // Live and NoteOnLatched take the SAME route out of here — one publish, no reload — so the + // shell's question is only "does this reload?". Which voices then read the published value + // is the engine's business (deck_groups.h). + return instrument::ui::liveCommitFor(k, paramId) != instrument::ui::LiveCommit::Reload; } void ReaSamplerEditor::closeCurvePopup() { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index df050eb..b16f0ce 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -276,14 +276,14 @@ private: // instrument off the audio thread. UI thread only. void commitAndReload(); - // The live peer of commitAndReload for a continuously-valued control (isLiveDeckParam): + // The live peer of commitAndReload for a continuously-valued control (deckParamCommit): // the same parameter-set write — so a saved project carries the edit exactly as before — // followed by a live publish instead of a rebuild, so the note already sounding follows // the knob. Does not repaint; callers already do. UI thread only. void commitLive(); // Whether an in-flight drag commits live rather than through a reload. A deck knob is - // live per isLiveDeckParam; an envelope-node drag is live in EITHER mode — see + // live per deckParamCommit; an envelope-node drag is live in EITHER mode — see // liveCommitFor (deck_groups.h) for why. bool dragCommitsLive(DragKind kind, int paramId = -1) const; diff --git a/tests/test_bake_reset.cpp b/tests/test_bake_reset.cpp index 3612ac9..2d14513 100644 --- a/tests/test_bake_reset.cpp +++ b/tests/test_bake_reset.cpp @@ -50,6 +50,8 @@ InstrumentParams dialed() { p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.shape.attackSeconds = 0.05; p.play.pitchVelocityCurve = VelocityCurve::linear(); + p.play.playRate = 0.5; + p.play.pitchOffsetSemitones = -7.5; p.play.filter.enabled = true; p.play.filter.modAmount = -0.8; p.play.filter.velAmount = 0.6; @@ -139,6 +141,15 @@ int main() { CHECK(after.play.pitchEnv.peakSemitones == 0.0); CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds); + // --- RESET: Rate and the baseline Pitch offset ----------------------------------- + // Both are processing the bake already printed, so the whitelist leaves them at their + // defaults — the safe direction. A second bake of the result at a still-dialled rate would + // otherwise re-stretch what the first one baked in. + CHECK(after.play.playRate == 1.0); + CHECK(after.play.pitchOffsetSemitones == 0.0); + CHECK(after.play.playRate == freshPlay.playRate); + CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones); + // --- RESET: the filter, including its velocity/key-tracking mod ----------------- CHECK(!after.play.filter.enabled); CHECK(after.play.filter.modAmount == 0.0); diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp index 02e43b9..8e8945c 100644 --- a/tests/test_bake_window.cpp +++ b/tests/test_bake_window.cpp @@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { return peak; } +// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped. +// A measurement of the engine, never a second evaluation of the derivation under test. -1 when +// the render is silent throughout. +std::int64_t lastSoundingFrame(const BakeAudio& audio) { + for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) { + if (std::fabs(static_cast( + audio.interleaved[static_cast(f * audio.channelCount)])) > kSilence) { + return f; + } + } + return -1; +} + // The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same // sound, a longer window). It leaves the derivation itself untouched, which is what makes the // comparison a measurement of the derived end rather than of a second derivation. @@ -92,6 +105,20 @@ std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) { return plan ? plan->totalFrames : -1; } +// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a +// deliberately long note and a window to match. This is the reference a derived window is +// judged against, and it has to be measured rather than recomputed — an under-derived Gate +// window truncates by releasing the note EARLY, which leaves no signal outside the file at all +// and so is invisible to "nothing past the end". +std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) { + NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100)); + p.length = lengthOfSeconds(heldSeconds); + p.end = EndOffset(offsetFromMs(200.0)); + const std::optional plan = planOf(p); + if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; } + return lastSoundingFrame(renderBake(s, *plan, kUnity)); +} + // The last frame of the file, which is where a hard cut shows up. double lastFrameLevel(const BakeAudio& audio) { return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount()) @@ -337,6 +364,107 @@ int main() { CHECK(derivedFrames(staged) == 12000 + kPad); } + // ============================ RATE AND PITCH ==================================== + + // The one judgement every case below makes: the derived window holds the WHOLE free-running + // sound (the derived render stops exactly where the uncut one does), and it is exactly + // enough rather than merely long. `heldSeconds` only has to exceed the free-running length. + const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds, + const char* what) { + const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds); + const std::int64_t derived = derivedFrames(s); + const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0)); + const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd; + CHECK(held); + CHECK(held && derived - trueEnd <= kPad + 8); + if (!(held && derived - trueEnd <= kPad + 8)) { + std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n", + what, static_cast(trueEnd), static_cast(got), + static_cast(derived)); + } + }; + + // --- Rate scales the window under BOTH engines, in both derived branches -------------- + // Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the + // stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and + // a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and + // prints a file of trailing silence at the fast one. + { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) { + for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) { + SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate + s.play.playMode = mode; + s.play.pitchEngine = eng; + s.play.adsr.releaseFrames = 0; + s.play.playRate = rate; + char what[64]; + std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f", + static_cast(eng), static_cast(mode), rate); + windowHoldsTheWholeNote(s, 3.0, what); + } + } + } + } + + // --- A downward Pitch offset stretches the window under VARISPEED only --------------- + // It is a factor of the read increment there and a shifter transpose under Preserve, so the + // window follows it in one engine and not the other. Both must still hold the whole note. + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime + + CHECK(derivedFrames(s) == 96000 + kPad); + windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12"); + + SampleData p = s; + p.play.pitchEngine = PitchEngine::Preserve; + CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved + windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12"); + + // An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the + // un-stretched span and the balance is trailing silence, on the same asymmetry the + // velocity->pitch term already takes. + SampleData up = s; + up.play.pitchOffsetSemitones = 12.0; + CHECK(derivedFrames(up) == 48000 + kPad); + const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0); + CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0); + } + + // --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ---------- + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.playRate = 0.5; + s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read + + CHECK(derivedFrames(s) == 192000 + kPad); + windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12"); + } + + // --- Gate over a sustain loop is Hold's, and Rate does not touch it ------------------- + // The note length there is the user's Hold in wall clock and the release is ticked per + // output frame, so neither term of the stretch applies — the one derived branch that must + // NOT move when Rate does. + { + SampleData s = dcSample(48000); + s.loop = SampleLoop{true, 0, 24000}; + s.play.playMode = PlayMode::Gate; + s.play.adsr.releaseFrames = 4800; + CHECK(bakeWindowNeedsHold(s)); + + const std::int64_t unity = derivedFrames(s); + for (double rate : {0.5, 2.0}) { + SampleData r = s; + r.play.playRate = rate; + CHECK(derivedFrames(r) == unity); + } + } + // ============================== VELOCITY ======================================== // --- The bake renders at the velocity it is handed ---------------------------------- diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 276734c..adf8fa4 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -10,6 +10,7 @@ #include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec // links no engine, and this adds none) #include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap) +#include "../src/core/instrument/engine/time_stretch.h" // the rate bounds the codec clamps to #include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral) #include @@ -453,7 +454,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0f,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x10,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -551,6 +552,9 @@ static void testGoldenFullBlobFixture() { 0x00, // Straight // --- payload v15 limiter enable --- 0x00, // bypassed (the default) + // --- payload v16 rate + baseline pitch offset --- + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // playRate 1.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitchOffsetSemitones 0.0 }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -598,10 +602,10 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 15); + CHECK(kParamsPayloadVersion == 16); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter tails rode + // The filter, staged-curve, loop, velocity, spline, bake-Hold, limiter and rate tails rode // PAYLOAD bumps, not envelope ones — the two axes stay independent, so a future envelope // field cannot collide with any of them on one number. This pins the NUMBERS only; that // each tail's bytes sit in the order its number implies is @@ -613,7 +617,8 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(kParamsSplineVersion > kParamsVelocityVersion); CHECK(kParamsBakeHoldVersion > kParamsSplineVersion); CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion); - CHECK(kParamsPayloadVersion == kParamsLimiterVersion); + CHECK(kParamsRateVersion > kParamsLimiterVersion); + CHECK(kParamsPayloadVersion == kParamsRateVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -798,9 +803,14 @@ static void testNonFiniteAhdSecondsLiftToZero() { static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2; static constexpr std::size_t kBakeHoldTailBytes = 4 + 1; static constexpr std::size_t kLimiterTailBytes = 1; +static constexpr std::size_t kRateTailBytes = 8 + 8; // v16: rate + pitch offset, two doubles +// Everything past the hard flags, as ONE unit — the splice tests cut back over all of it, so a +// new rung is one edit here rather than a hand-counted sum at each of them. +static constexpr std::size_t kTrailingTailBytes = + kBakeHoldTailBytes + kLimiterTailBytes + kRateTailBytes; -// The v14/v15 tails, re-appended after a splice so the record still ends where the reader -// expects. They go back in wire order: Hold first, then the limiter byte. +// The v14/v15/v16 tails, re-appended after a splice so the record still ends where the reader +// expects. They go back in wire order: Hold, then the limiter byte, then the rate pair. static void putBakeHoldTail(std::vector& out, int quarterExponent, note::DivisionModifier modifier) { legacy::u32v(out, static_cast(static_cast(quarterExponent))); @@ -811,9 +821,15 @@ static void putLimiterTail(std::vector& out, bool enabled) { legacy::u8v(out, enabled ? 1 : 0); } +static void putRateTail(std::vector& out, double rate, double pitchOffset) { + legacy::f64v(out, rate); + legacy::f64v(out, pitchOffset); +} + static void putDefaultTrailingTails(std::vector& out) { putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default putLimiterTail(out, false); // bypassed, the field's default + putRateTail(out, 1.0, 0.0); // unity rate, no offset } // A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still @@ -848,8 +864,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { // order in params_payload.cpp) is deterministic and this test can splice it exactly. std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes); legacy::u32v(bytes, 5); // amp: bogus count... for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts legacy::u32v(bytes, 2); // filter: correct count, unchanged @@ -896,8 +912,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { in.params.loopCrossfadeFrames = 321; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes); legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry // …and nothing at all after it, so the blob simply ends inside the v13 tail. @@ -947,7 +963,7 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric // Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the // limiter byte — is exactly what the serializer wrote, which is the whole hazard. constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2); - constexpr std::size_t kTrailingTails = kBakeHoldTailBytes + kLimiterTailBytes; + constexpr std::size_t kTrailingTails = kTrailingTailBytes; std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails); const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails; @@ -1044,10 +1060,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() in.params.loopCrossfadeFrames = 5; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes); // Drops the bake-Hold and limiter tails with the flags: the truncation strands everything // after it, which is the whole point — both lift to their defaults alongside the flags. - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes); legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing legacy::u8v(bytes, 0x00); @@ -1255,18 +1271,18 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() CHECK(out.params.keyTrack == 0.25); CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); - // The same state stamped v14, with exactly the one appended byte cut away: byte-for-byte + // The same state stamped v14, with the two rungs appended after it cut away: byte-for-byte // what the Ξ binary wrote. Its Hold must survive in full. const ComponentState v14 = deserializeComponentState( - payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 48000.0); + payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes + kRateTailBytes), + 48000.0); CHECK(!v14.params.limiterEnabled); CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); CHECK(v14.params.keyTrack == 0.25); // And a v13 blob, one rung further back, lifts to BOTH defaults. const ComponentState v13 = deserializeComponentState( - payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes), - 48000.0); + payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes), 48000.0); CHECK(!v13.params.limiterEnabled); CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(v13.params.keyTrack == 0.25); @@ -1280,37 +1296,49 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() // The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole // discipline is that each version's fields are a strict suffix on the previous version's, so -// v14's Hold pair must be emitted BEFORE v15's limiter byte or every v14 blob already saved -// mis-parses. Asserted at absolute offsets from the end of the blob, with both fields off -// their defaults, so transposing the two writes fails on the values and not just the layout. +// v14's Hold pair must be emitted BEFORE v15's limiter byte, and both before v16's rate pair, +// or every blob already saved at those rungs mis-parses. Asserted at absolute offsets from the +// end of the blob, with every field off its default, so transposing any two writes fails on the +// values and not just the layout. static void testAppendedTailsSitInVersionOrderOnTheWire() { ComponentState in; in.selectionId = "pad"; in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); in.params.limiterEnabled = true; + in.params.play.playRate = 2.0; // 0x4000000000000000 LE + in.params.play.pitchOffsetSemitones = -12.0; // 0xC028000000000000 LE const std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes); + CHECK(bytes.size() > kTrailingTailBytes); - // The last six bytes are, in order: the v14 Hold's 4-byte LE exponent, its 1-byte - // modifier, then the v15 limiter byte. - const std::size_t holdAt = bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes; + // In order: the v14 Hold's 4-byte LE exponent, its 1-byte modifier, the v15 limiter byte, + // then the v16 rate and pitch-offset doubles. + const std::size_t holdAt = bytes.size() - kTrailingTailBytes; CHECK(bytes[holdAt + 0] == 0xfe); // -2 as int32 LE two's-complement CHECK(bytes[holdAt + 1] == 0xff); CHECK(bytes[holdAt + 2] == 0xff); CHECK(bytes[holdAt + 3] == 0xff); CHECK(bytes[holdAt + 4] == static_cast(note::DivisionModifier::Triplet)); - CHECK(bytes[bytes.size() - 1] == 0x01); // the limiter enable, last + CHECK(bytes[holdAt + 5] == 0x01); // the limiter enable + const std::size_t rateAt = holdAt + kBakeHoldTailBytes + kLimiterTailBytes; + const std::uint8_t wantRate[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40}; + const std::uint8_t wantOffset[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0xc0}; + for (std::size_t i = 0; i < 8; ++i) { + CHECK(bytes[rateAt + i] == wantRate[i]); + CHECK(bytes[rateAt + 8 + i] == wantOffset[i]); + } - // The same claim from the other side: flipping only the limiter changes only the LAST - // byte, so the byte the limiter owns cannot be one the Hold also writes. + // The same claim from the other side: flipping only the limiter changes only the byte the + // limiter owns, so it cannot be one the Hold or the rate pair also writes. ComponentState off = in; off.params.limiterEnabled = false; const std::vector offBytes = serializeComponentState(off); CHECK(offBytes.size() == bytes.size()); if (offBytes.size() == bytes.size()) { - for (std::size_t i = 0; i + 1 < bytes.size(); ++i) CHECK(offBytes[i] == bytes[i]); - CHECK(offBytes[bytes.size() - 1] == 0x00); + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (i == holdAt + 5) CHECK(offBytes[i] == 0x00); + else CHECK(offBytes[i] == bytes[i]); + } } } @@ -1408,10 +1436,10 @@ static void testV13BlobLiftsToTheDefaultHold() { in.params.loopCrossfadeFrames = 128; in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted); - // Stamp the payload back to v13 and drop the v14 and v15 tails both: byte-for-byte what - // the v13 binary would have written. - const std::vector v13 = payloadDowngradedTo( - in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes); + // Stamp the payload back to v13 and drop every tail appended since: byte-for-byte what the + // v13 binary would have written. + const std::vector v13 = + payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes); const ComponentState out = deserializeComponentState(v13, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); @@ -1427,18 +1455,22 @@ static void testBakeHoldCorruptPairClampsToTheLadder() { ComponentState in; in.selectionId = "pad"; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kTrailingTailBytes); + bytes.resize(bytes.size() - kTrailingTailBytes); legacy::u32v(bytes, static_cast(static_cast(9999))); legacy::u8v(bytes, 200); // an unnamed modifier byte - putLimiterTail(bytes, true); // a well-formed byte after it, so the clamp is the only fault + // Well-formed, off-default tails after it, so the clamp is the only fault in the blob. + putLimiterTail(bytes, true); + putRateTail(bytes, 0.5, 7.0); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); - // The tail behind the corrupt pair still lands on its own field: the clamp consumed exactly - // the five bytes it was owed, so the limiter byte was not read out of the Hold's modifier. + // The tails behind the corrupt pair still land on their own fields: the clamp consumed + // exactly the five bytes it was owed, so nothing after it was read out of alignment. CHECK(out.params.limiterEnabled); + CHECK(out.params.play.playRate == 0.5); + CHECK(out.params.play.pitchOffsetSemitones == 7.0); } // A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded @@ -1451,22 +1483,106 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { in.params.loopCrossfadeFrames = 96; in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet); in.params.limiterEnabled = true; + in.params.play.playRate = 0.75; + in.params.play.pitchOffsetSemitones = -5.0; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kTrailingTailBytes); + bytes.resize(bytes.size() - kTrailingTailBytes); legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing legacy::u8v(bytes, 0x00); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(!out.params.limiterEnabled); // stranded behind the Hold, and revived not wiped + // The rate pair is stranded two rungs behind the damage and must reach its own neutral + // rather than fabricating one out of the drained bytes. + CHECK(out.params.play.playRate == 1.0); + CHECK(out.params.play.pitchOffsetSemitones == 0.0); CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 71); CHECK(out.params.play.adsr.attackSeconds == 0.017); CHECK(out.params.loopCrossfadeFrames == 96); } +// --- The rate + pitch-offset tail (payload v16) ------------------------------ + +// The rung's whole contract in one test: a v16 blob round-trips BOTH fields exactly, and a v15 +// blob — a strict prefix of it, byte-for-byte what the shipped binary wrote — lifts to unity +// rate and zero offset, which is what every instance before them played. The neighbours ahead of +// the pair are checked too, so a misread that shifted the record shows up here rather than as a +// silent retune. +static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() { + ComponentState in; + in.selectionId = "pad"; + in.params.keyTrack = 0.75; + in.params.limiterEnabled = true; + in.params.bakeHold = note::makeDivision(3, note::DivisionModifier::Dotted); + // Both off their defaults, and both exactly representable, so == is the right comparison: + // the codec stores raw doubles and must not round either one. + in.params.play.playRate = 0.75; + in.params.play.pitchOffsetSemitones = -7.5; + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.play.playRate == 0.75); + CHECK(out.params.play.pitchOffsetSemitones == -7.5); + CHECK(out.params.limiterEnabled); + CHECK(out.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted)); + CHECK(out.params.keyTrack == 0.75); + + const ComponentState v15 = deserializeComponentState( + payloadDowngradedTo(in, kParamsLimiterVersion, kRateTailBytes), 48000.0); + CHECK(v15.params.play.playRate == 1.0); + CHECK(v15.params.play.pitchOffsetSemitones == 0.0); + // Everything the v15 binary DID write survives the lift untouched. + CHECK(v15.params.limiterEnabled); + CHECK(v15.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted)); + CHECK(v15.params.keyTrack == 0.75); + + // Unity/zero is the default at the struct as well as on the wire, so a fresh instance and a + // lifted v15 one are the same sound. + CHECK(PlaySeconds{}.playRate == 1.0); + CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0); +} + +// Corruption degrades to the neutral, and an out-of-RANGE rate resolves through the stretcher's +// own clamp rather than surviving unclamped: playback would clamp it anyway, so a stored value +// that did not would leave the needle — and the host normalization, once the instrument reports +// parameters — disagreeing with what is actually played. The offset has no such downstream clamp +// at all (it feeds a 2^(x/12) that reaches a per-sample cast), so it gets a real range test and +// degrades whole. +static void testCorruptRateOrOffsetDegradesToTheNeutral() { + const double nan = std::numeric_limits::quiet_NaN(); + const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = { + {nan, 3.0, 1.0, 3.0}, + {0.75, nan, 0.75, 0.0}, + {0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head + {-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards + {std::numeric_limits::infinity(), 3.0, 1.0, 3.0}, + // Finite but out of the stretcher's range — reachable from a downgrade, not corruption. + // Clamped to the bound the engine would have played, not left to re-serialize. + {10.0, 3.0, instrument::engine::kStretchRateMax, 3.0}, + {0.01, 3.0, instrument::engine::kStretchRateMin, 3.0}, + {instrument::engine::kStretchRateMin, 3.0, instrument::engine::kStretchRateMin, 3.0}, // the bounds themselves + {instrument::engine::kStretchRateMax, 3.0, instrument::engine::kStretchRateMax, 3.0}, // survive untouched + {0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw + {0.75, -1e9, 0.75, 0.0}, + {0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range + {0.75, -24.0, 0.75, -24.0}, + }; + for (const auto& c : cases) { + ComponentState in; + in.selectionId = "pad"; + std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() >= kRateTailBytes); + bytes.resize(bytes.size() - kRateTailBytes); + putRateTail(bytes, c.rate, c.offset); + const ComponentState out = deserializeComponentState(bytes, 48000.0); + CHECK(out.params.play.playRate == c.wantRate); + CHECK(out.params.play.pitchOffsetSemitones == c.wantOffset); + } +} + // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of // the payload — the self-describing property every legacy branch depends on. Asserted // against the semantic constants, not literals. @@ -2079,6 +2195,8 @@ int main() { testV13BlobLiftsToTheDefaultHold(); testBakeHoldCorruptPairClampsToTheLadder(); testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord(); + testRateAndPitchOffsetRoundTripAndV15LiftsToUnity(); + testCorruptRateOrOffsetDegradesToTheNeutral(); if (failures == 0) { std::printf("component_state_io_tests: all tests passed\n"); return 0; diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 5069705..6fa95b9 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -412,10 +412,12 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { CHECK(deckNormFromBipolar(3.0) == 1.0); } -static void testEveryDeckControlIsClassifiedLiveOrReloading() { - // The live set: the seven filter tone/modulation knobs, plus every stage time, stage level, - // hold fraction and curve exponent on all three envelopes — in BOTH mode shapes. +static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { + // The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus + // every stage time, stage level, hold fraction and curve exponent on all three envelopes — + // in BOTH mode shapes. const DeckParam live[] = { + DeckParam::kPitch, DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ, DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel, DeckParam::kFilterKeyTrack, @@ -434,7 +436,14 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { DeckParam::kFilterEnvReleaseCurve, DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve, }; - for (DeckParam p : live) CHECK(isLiveDeckParam(p)); + for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live); + + // The note-on-latched tier: published like a live control, read only at note-on. Asserted as + // its OWN state rather than as "not Reload" — the whole point of widening the predicate is + // that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification + // to decide what it exposes to the host. + const DeckParam latched[] = {DeckParam::kRate}; + for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched); // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion // is excluded. @@ -448,15 +457,16 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { DeckParam::kVoiceCount, DeckParam::kVoiceMode, DeckParam::kMonoTrigger, DeckParam::kMasterGain, }; - for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p)); + for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); - // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check + // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three lists. A sum check // would stay green if an edit duplicated one id and dropped another, leaving that one // unclassified. for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { const DeckParam p = static_cast(i); int seen = 0; for (DeckParam q : live) if (q == p) ++seen; + for (DeckParam q : latched) if (q == p) ++seen; for (DeckParam q : reloads) if (q == p) ++seen; if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen); CHECK(seen == 1); @@ -464,26 +474,34 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { } static void testOnlyALiveControlsDragTakesTheLiveTier() { - // isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's + // deckParamCommit alone is not what a user experiences — liveCommitFor is, at the editor's // commit site. Inverting it has to FAIL a test rather than merely read wrong. - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kFilterCutoff))); - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAttack))); + const auto knob = [](DeckParam p) { + return liveCommitFor(LiveDragKind::kDeckKnob, static_cast(p)); + }; + CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live); + CHECK(knob(DeckParam::kAttack) == LiveCommit::Live); + CHECK(knob(DeckParam::kPitch) == LiveCommit::Live); // The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural // consequence of that consolidation. - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigAttack))); - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigDecayCurve))); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigLength))); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kMasterGain))); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAmpEnvSelect))); + CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live); + CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live); + // Rate keeps its own tier through the drag site: it must not arrive as Live (which would let + // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob). + CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched); + CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); + CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload); + CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload); // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id // are not parameter-set controls, so they must never reach the enum. - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2)); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1)); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kCount))); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -2) == LiveCommit::Reload); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload); + CHECK(knob(DeckParam::kCount) == LiveCommit::Reload); // Every stage value an envelope node can reach is live, in either mode shape. - CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1)); + CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live); // Every other drag (markers, scrollbar, curve nodes) commits through a reload. - CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff))); + CHECK(liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff)) == + LiveCommit::Reload); } // --- The overlay selection state machine --------------------------------------- @@ -582,9 +600,9 @@ static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { // that scale either shape stay live. (Which segment knobs, per envelope, is pinned in // spline_egs_tests alongside the rest of the spline rules.) static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() { - CHECK(!isLiveDeckParam(DeckParam::kAmpEnvMode)); - CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode)); - CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode)); + CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload); + CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload); + CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload); CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); @@ -644,6 +662,31 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { // The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned // once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here. +// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus +// padding) is what it measures from, and the caption row must stay under that. The ceiling is +// asserted by construction rather than as a comment: at a caption reserve of 80 the group is +// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the +// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is. +static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc* pitch = nullptr; + for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d; + CHECK(pitch != nullptr); + if (!pitch) return; + CHECK(pitch->cellIds.size() == 3); + CHECK(pitch->cellIds[0] == static_cast(DeckParam::kKeyTrack)); + CHECK(pitch->cellIds[1] == static_cast(DeckParam::kRate)); + CHECK(pitch->cellIds[2] == static_cast(DeckParam::kPitch)); + CHECK(deckGroupWidth(*pitch) == 192); + CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement + + DeckGroupDesc probe = *pitch; + probe.captionWidth = 80; + CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it + probe.captionWidth = 81; + CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over +} + // Gate is the common face and its group widths are what the width budget is spent against: // pin them at the floor so a later edit anywhere in the deck cannot move one silently. // (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a @@ -652,7 +695,7 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { static void testGateModeGroupWidthsAreUnchanged() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const struct { int id; int width; } want[] = { - {kGroupPitch, 150}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, + {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, {kGroupVoice, 164}, {kGroupMaster, 72}, }; @@ -736,7 +779,7 @@ int main() { testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); testAModeToggleIsNeitherLiveNorAnOverlayRadio(); testTheModeTogglesCostNoGroupWidth(); - testEveryDeckControlIsClassifiedLiveOrReloading(); + testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers(); testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); @@ -750,6 +793,7 @@ int main() { testWrappedDeckHeightAtTheEditorFloorWidth(); testDeckFitsInsideTheEnforcedMinimumWindow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); + testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); testGateModeGroupWidthsAreUnchanged(); testGateSplineGateRoundTripsToTheSameLayout(); testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index d82090b..65eaef6 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -14,6 +14,7 @@ using namespace reasampler; using namespace reasampler::instrument::ui; +namespace engine = reasampler::instrument::engine; // the stretcher's own rate bounds + clamp static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -88,6 +89,88 @@ static void testNormRoundTripsThroughEveryValueDomain() { CHECK(p.adsr.decaySeconds == 0.0); } +// Rate's range is the STRETCHER's, aliased rather than restated, so the knob's two ends and the +// engine's clamp cannot become two opinions. Asserted against the engine constants themselves. +static void testRateKnobEndsAreTheStretchersOwnBounds() { + CHECK(kRateMinRatio == engine::kStretchRateMin); + CHECK(kRateMaxRatio == engine::kStretchRateMax); + PlaySeconds p; + setDeckParam(DeckParam::kRate, p, 0.0, 0); + CHECK(p.playRate == engine::kStretchRateMin); + CHECK(engine::clampStretchRate(p.playRate) == p.playRate); // the clamp has nothing to do + setDeckParam(DeckParam::kRate, p, 1.0, 0); + CHECK(p.playRate == engine::kStretchRateMax); + CHECK(engine::clampStretchRate(p.playRate) == p.playRate); + // And nowhere on the travel does the knob produce a rate the engine would move. + for (int i = 0; i <= 1000; ++i) { + setDeckParam(DeckParam::kRate, p, static_cast(i) / 1000.0, 0); + CHECK(engine::clampStretchRate(p.playRate) == p.playRate); + if (engine::clampStretchRate(p.playRate) != p.playRate) return; + } +} + +// The two new bindings write the two new fields and nothing else — both are doubles on +// PlaySeconds with adjacent homes, so a getter/setter pair that crossed them would still +// round-trip. The centre detent is exact on both, which is what lets an untouched knob persist +// unity rate and zero transposition. +static void testRateAndPitchBindTheirOwnFields() { + PlaySeconds p; + setDeckParam(DeckParam::kRate, p, 0.5, 0); + CHECK(p.playRate == 1.0); + CHECK(p.pitchOffsetSemitones == 0.0); + CHECK(deckParamNorm(DeckParam::kRate, p) == 0.5); + + setDeckParam(DeckParam::kPitch, p, 0.5, 0); + CHECK(p.pitchOffsetSemitones == 0.0); + CHECK(p.playRate == 1.0); + CHECK(deckParamNorm(DeckParam::kPitch, p) == 0.5); + + // Pitch rides the SAME centre-expanded depth taper as the pitch envelope's own depth, over + // the SAME throw — a second constant here would be the defect the spec names. + setDeckParam(DeckParam::kPitch, p, 1.0, 0); + CHECK(p.pitchOffsetSemitones == kPitchDepthMaxSemis); + CHECK(kPitchDepthMaxSemis == kVelocityPitchRangeSemitones); + setDeckParam(DeckParam::kPitch, p, 0.0, 0); + CHECK(p.pitchOffsetSemitones == -kPitchDepthMaxSemis); + CHECK(p.playRate == 1.0); // untouched by every write above but its own + + // A move on Rate leaves the offset alone, in the other direction. + setDeckParam(DeckParam::kPitch, p, 0.5, 0); + setDeckParam(DeckParam::kRate, p, 0.0, 0); + CHECK(p.pitchOffsetSemitones == 0.0); +} + +// Shift's whole unit on BOTH new knobs is the semitone, not the percent their labels read in. +// Asserted through the deck's own snap entry point (the shell calls nothing else), and in +// semitones, which is the unit the rule is stated in. +static void testShiftSnapsBothNewKnobsToWholeSemitones() { + CHECK(deckParamUnit(DeckParam::kRate) == UnitCategory::Semitones); + CHECK(deckParamUnit(DeckParam::kPitch) == UnitCategory::Semitones); + + PlaySeconds p; + // Rate: a norm a third of the way up is 8 semitones below unity — snapping must land on a + // whole one, and the knob must still be able to reach an octave and a fifth by hand. + for (double norm : {0.13, 0.37, 0.5, 0.62, 0.88}) { + setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, norm), 0); + const double semis = 12.0 * std::log2(p.playRate); + CHECK(std::fabs(semis - std::round(semis)) < 1e-9); + if (!(std::fabs(semis - std::round(semis)) < 1e-9)) return; + } + // The two landmarks by name: unity, and a fifth up. + setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5), 0); + CHECK(p.playRate == 1.0); + setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5 + 7.0 / 24.0), 0); + CHECK(std::fabs(12.0 * std::log2(p.playRate) - 7.0) < 1e-9); + + // Pitch: whole semitones on the centre-expanded taper, exactly (its taper resolves onto a + // micro-semitone grid, so a whole semitone is ON that grid). + for (double norm : {0.17, 0.33, 0.71, 0.94}) { + setDeckParam(DeckParam::kPitch, p, snapDeckParamNorm(DeckParam::kPitch, norm), 0); + CHECK(p.pitchOffsetSemitones == std::round(p.pitchOffsetSemitones)); + if (p.pitchOffsetSemitones != std::round(p.pitchOffsetSemitones)) return; + } +} + // The dual-ring reset contract: the outer ring resets the stage VALUE and the inner dial resets // the EXPONENT, each leaving the other exactly as it was. Both fields are asserted in both // directions — checking only the field that changed would pass even if the reset clobbered its @@ -234,6 +317,12 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() { CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction); CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction); CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount); + // The PITCH/RATE pair. Rate's preimage is the taper's unity detent, which sits at true + // centre only because these bounds are reciprocal; Pitch's is the depth taper's exact zero. + CHECK(rateRatioFromNorm(deckParamNorm(DeckParam::kRate, d), kRateMinRatio, kRateMaxRatio) == + d.playRate); + CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitch, d), kPitchDepthMaxSemis) == + d.pitchOffsetSemitones); CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) == d.adsr.attackCurve); // Master gain's unity: the case where a hair off is an audible gain error rather than a @@ -342,6 +431,9 @@ static void testTimeConstantsAlwaysReadInMilliseconds() { int main() { testTheTwoCeilingNamesAreOneNumber(); testNormRoundTripsThroughEveryValueDomain(); + testRateKnobEndsAreTheStretchersOwnBounds(); + testRateAndPitchBindTheirOwnFields(); + testShiftSnapsBothNewKnobsToWholeSemitones(); testResetTouchesOnlyItsOwnRingOnADualRingKnob(); testInnerResetLandsOnTheExactLinearNeutral(); testResetLandsOnTheStoredDefaultOfEachControl(); diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index 8cfdf6b..a580aa0 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { PitchEnvParams longer = p; longer.shape.decayFrames = 2000; - b.applyLive(longer); // decay doubled mid-decay + b.applyLive(100000, longer); // decay doubled mid-decay, same span CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame // A depth move is a level step, so it glides rather than jumping: the first frame after @@ -224,7 +224,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); } PitchEnvParams noDepth = p; noDepth.peakSemitones = 0.0; - c.applyLive(noDepth); // depth to zero mid-decay + c.applyLive(100000, noDepth); // depth to zero mid-decay CHECK(c.tick() == d.tick()); // ...and it does eventually reach the new depth rather than staying put. for (int i = 0; i < 400; ++i) c.tick(); @@ -259,7 +259,7 @@ static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() { for (int i = 0; i < 300; ++i) f.tick(); PitchEnvParams wider = p; wider.shape.holdFraction = 1.0; - f.applyLive(wider); + f.applyLive(1000, wider); CHECK(f.tick() == 12.0); for (int i = 0; i < 1200; ++i) f.tick(); CHECK(f.tick() == 0.0); @@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() { PitchEnvParams dialled = stale; dialled.peakSemitones = 12.0; dialled.shape.decayFrames = 1000; - env.snapLive(dialled); + env.snapLive(100000, dialled); CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope for (int i = 0; i < 499; ++i) env.tick(); CHECK(std::fabs(env.tick() - 6.0) < 1e-12); @@ -720,6 +720,224 @@ static void testOneBlockServesTwoIndependentObservers() { CHECK(seen.filterSettings.cutoffNorm == 0.2f); } +// --- The third commit class: published live, read only at note-on ------------------------- + +// A ramp source read under Varispeed, so every output frame IS the read position — a moved read +// increment shows up directly rather than as a timbre change. The claim has two halves and both +// are asserted: the sounding note is byte-identical to one that never saw the publish, AND the +// next note-on takes the new rate. Asserting only the first would pass on a rate that never +// arrived at all. +static SampleData rampForReadRate() { + SampleData s; + s.frames.resize(200000); + for (std::size_t i = 0; i < s.frames.size(); ++i) { + s.frames[i] = static_cast(static_cast(i) / 200000.0); + } + s.sampleRate = kRate; + s.rootNote = 60; + s.play.adsr.sustainLevel = 1.0; + return s; +} + +// A one-voice engine with its Preserve shifters actually SIZED, unlike renderWithLive's — the +// shared harness leaves them unconfigured, which silently routes a Preserve voice down the +// varispeed read and would make "in both engines" mean one engine twice. +static std::vector renderPreserveCapable(SampleData& s, LiveParams& block, + const LiveValues* changed, int changeAfter, + int note) { + s.live = █ + block.publish(foldLive(s.play)); + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(note, 100); + std::vector out; + for (int b = 0; b < 24; ++b) { + if (changed && b == changeAfter) block.publish(*changed); + engine.render(out, 512); + } + return out; +} + +static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + SampleData still = rampForReadRate(); + SampleData moved = rampForReadRate(); + still.play.pitchEngine = eng; + moved.play.pitchEngine = eng; + + LiveParams blockA, blockB; + LiveValues halfRate = foldLive(moved.play); + halfRate.playRate = 0.5; + + // At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the + // output is then the source at the read head under both engines, which is what makes + // the ramp readable as a read rate at all. + const std::vector baseline = + renderPreserveCapable(still, blockA, nullptr, -1, 60); + const std::vector swept = + renderPreserveCapable(moved, blockB, &halfRate, 8, 60); + + // BYTE-identical, not merely close: the sounding voice never reads the field. + CHECK(baseline.size() == swept.size()); + bool untouched = true; + for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) { + if (baseline[i] != swept[i]) { untouched = false; break; } + } + CHECK(untouched); + + // The next note-on takes it — measured as the note's LIFETIME, which is what Rate + // controls in both engines. (The ramp's instantaneous value is a read-position probe + // under Varispeed only: under Preserve the shifter's tap sits behind the feed and + // relocates at every splice, so the value at a given output frame is not the source + // there.) The rate is carried ONLY by the published block — sample.play keeps unity — + // so a lifetime that doubles can only have come from the block. + auto blocksAlive = [&](double rate) { + SampleData fresh = rampForReadRate(); + fresh.play.pitchEngine = eng; + LiveParams block; + fresh.live = █ + LiveValues published = foldLive(fresh.play); + published.playRate = rate; + block.publish(published); + VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 100); + std::vector out; + int blocks = 0; + while (engine.activeVoiceCount() > 0 && blocks < 4000) { + engine.render(out, 512); + ++blocks; + } + return blocks; + }; + const int atUnity = blocksAlive(1.0); + const int atHalf = blocksAlive(0.5); + CHECK(atUnity > 100 && atUnity < 4000); // the note really did run to its own end + CHECK(std::fabs(static_cast(atHalf) - 2.0 * atUnity) < 0.05 * atUnity); + } +} + +// Pitch is the other side of the same coin: it DOES move the note already sounding, under both +// engines — one more factor of the read increment under Varispeed, an addend to the shift under +// Preserve. Measured as a tail that departs from the untouched render while the frames before +// the publish stay byte-identical. +static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + SampleData still = periodicSine(200000, 64.0); + SampleData moved = periodicSine(200000, 64.0); + still.play.pitchEngine = eng; + moved.play.pitchEngine = eng; + + LiveParams blockA, blockB; + LiveValues target = foldLive(moved.play); + target.pitchOffsetSemitones = -12.0; + + const std::vector baseline = + renderPreserveCapable(still, blockA, nullptr, -1, kTestNote); + const std::vector swept = + renderPreserveCapable(moved, blockB, &target, 8, kTestNote); + + double tailDiff = 0.0; + for (std::size_t i = 512 * 12; i < baseline.size(); ++i) { + tailDiff += std::fabs(static_cast(swept[i]) - + static_cast(baseline[i])); + } + CHECK(tailDiff > 1.0); + + bool preChangeIdentical = true; + for (std::size_t i = 0; i < 512 * 8; ++i) { + if (swept[i] != baseline[i]) { preChangeIdentical = false; break; } + } + CHECK(preChangeIdentical); + } +} + +// --- The live Pitch offset reaches the note's TIME domains, not only its pitch ------------- + +// A block published BEFORE the note starts is the snapLive path, and the snapshot's own copy of +// the offset is deliberately stale there — so this is where a Pitch offset has to be in hand +// already when the note's envelopes are fitted against the read rate. Answers how many output +// frames the voice sounded for, to a 256-frame block. +static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis, + std::size_t capFrames) { + LiveParams block; + LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy + v.pitchOffsetSemitones = offsetSemis; + block.publish(v); + s.live = █ + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 127); + std::vector out; + std::size_t life = 0; + while (out.size() < capFrames && engine.activeVoiceCount() > 0) { + engine.render(out, 256); + life = out.size(); + } + return life; +} + +// Under Varispeed the Pitch offset is a factor of the read increment, and the staged AHD is +// evaluated at the SOURCE offset that increment advances — so its stage frames are fitted to the +// offset the note will ACTUALLY play at, exactly as they are to Rate. The attack therefore +// completes on the same output frame at every offset. Fitting against the snapshot's stale zero +// instead is what this catches. +static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() { + constexpr std::int64_t kAttack = 2000; + for (double semis : {-12.0, 0.0, 12.0}) { + SampleData s; + s.frames.assign(96000, 1.0f); // DC: the output IS the amp envelope + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + + LiveParams block; + LiveValues v = foldLive(s.play); + v.pitchOffsetSemitones = semis; + block.publish(v); + s.live = █ + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 127); + std::vector out; + engine.render(out, 8000); + std::size_t reachedFull = 0; + for (std::size_t i = 0; i < out.size(); ++i) { + if (out[i] > 0.99f) { reachedFull = i; break; } + } + const bool ok = reachedFull > 0 && + std::fabs(static_cast(reachedFull) - + static_cast(kAttack)) < 40.0; + CHECK(ok); + if (!ok) std::printf(" pitch %+.1f st: attack completed at %zu\n", semis, reachedFull); + } +} + +// The pitch envelope's SPAN is a wall-clock duration converted from the same read rate, so it +// follows the published offset too. Read out as the note's LIFETIME: the envelope's depth +// cancels the offset while it holds, so the read runs at unity for the hold and at the offset +// ratio after it — which makes the lifetime a direct readout of where the hold ended. +// 12000 source frames, offset -12 st (read at 0.5): the span is 24000 output frames, its +// half-span hold is 12000 of them at unity, and the source is exhausted exactly there. +// A span fitted to the stale zero offset is 12000, holds for 6000, and the remaining 6000 +// source frames then take 12000 more output frames — 18000 in total. +static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() { + SampleData s; + s.frames.assign(12000, 1.0f); + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigAhd = AhdParams{0, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.peakSemitones = 12.0; // cancels the -12 offset while it holds + s.play.pitchEnv.shape.attackFrames = 0; + s.play.pitchEnv.shape.decayFrames = 0; + s.play.pitchEnv.shape.holdFraction = 0.5; + + const std::size_t life = soundingBlocksWithPublishedPitch(s, -12.0, 60000); + CHECK(life > 11000 && life < 13000); + if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life); +} + // --- What stays latched at note-on ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { @@ -859,6 +1077,10 @@ int main() { testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote(); testEveryLiveFilterControlMovesTheSoundingNote(); testOneBlockServesTwoIndependentObservers(); + testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); + testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); + testAPublishedPitchOffsetLeavesTheStagedAttackWallClock(); + testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan(); testPitchRatioAndVelocityGainStayLatched(); testVelocityGainSurvivesAHostilePublishThatReallyLands(); if (g_fail == 0) std::printf("live_delivery tests passed\n"); diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp index 4cf38f5..a4ef4e2 100644 --- a/tests/test_param_taper.cpp +++ b/tests/test_param_taper.cpp @@ -24,6 +24,11 @@ static int g_fail = 0; std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) static constexpr double kDepth = 24.0; // the pitch-depth throw the deck passes in today +// Rate's bounds, as the deck passes them in — the stretcher's own measured range. Written as +// literals HERE on purpose: this is the module's test, and reading the engine constant would +// make the test agree with the taper by construction rather than pin the numbers. +static constexpr double kRateMin = 0.5; +static constexpr double kRateMax = 2.0; // --- modifiers ----------------------------------------------------------------------------- @@ -227,6 +232,109 @@ static void testDegenerateThrowCollapsesToCentre() { CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0); } +// --- the rate taper ------------------------------------------------------------------------- + +// The three landmarks the range is specified by, all EXACT: half rate at norm 0, double at norm +// 1, and unity at TRUE knob centre — the last is what a detent has to be, and a map that merely +// came close to 1.0 there would persist a hair of transposition on an untouched knob. +static void testRateEndpointsAndCentreAreExact() { + CHECK(rateRatioFromNorm(0.0, kRateMin, kRateMax) == 0.5); + CHECK(rateRatioFromNorm(1.0, kRateMin, kRateMax) == 2.0); + CHECK(rateRatioFromNorm(0.5, kRateMin, kRateMax) == 1.0); + CHECK(rateNormFromRatio(0.5, kRateMin, kRateMax) == 0.0); + CHECK(rateNormFromRatio(2.0, kRateMin, kRateMax) == 1.0); + CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5); + // Out of domain clamps rather than extrapolating — the map cannot reach a ratio the + // engine's own clamp would then have to move. + CHECK(rateRatioFromNorm(-1.0, kRateMin, kRateMax) == 0.5); + CHECK(rateRatioFromNorm(2.0, kRateMin, kRateMax) == 2.0); + CHECK(rateNormFromRatio(0.1, kRateMin, kRateMax) == 0.0); + CHECK(rateNormFromRatio(9.0, kRateMin, kRateMax) == 1.0); +} + +// The taper's defining property, and the reason it is the exception to centre expansion: equal +// travel buys equal SEMITONES, everywhere. Checked as a constant ratio-of-ratios across the +// travel rather than at the two ends, which a centre-expanded map would also pass. +static void testRateIsLinearInSemitonesAcrossTheWholeTravel() { + const double step = 1.0 / 24.0; // 24 equal steps over 24 semitones + for (int i = 0; i < 24; ++i) { + const double lo = rateRatioFromNorm(static_cast(i) * step, kRateMin, kRateMax); + const double hi = rateRatioFromNorm(static_cast(i + 1) * step, kRateMin, kRateMax); + CHECK(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12); + if (!(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12)) return; + } + // The named musical landmarks that buys: an octave at each end, a fifth seven steps out. + CHECK(std::fabs(rateRatioFromNorm(0.5 + 7.0 / 24.0, kRateMin, kRateMax) - + std::exp2(7.0 / 12.0)) < 1e-12); +} + +static void testRateIsMonotone() { + double prev = -1.0; + for (int i = 0; i <= 200000; ++i) { + const double v = rateRatioFromNorm(static_cast(i) / 200000.0, kRateMin, kRateMax); + CHECK(v >= prev); + if (v < prev) return; + prev = v; + } +} + +// The preimage obligation this control actually carries: its ONE default, bitwise, because a +// host's reset-to-default arrives as toPlain(defaultNorm) with no editor bypass to intercept it. +// Both endpoints are exact for the same reason. Everything between round-trips to within an ulp +// rather than bitwise — the map carries no output quantum, and the header says why. +static void testRateDefaultAndEndpointsRoundTripBitwise() { + CHECK(rateRatioFromNorm(rateNormFromRatio(1.0, kRateMin, kRateMax), kRateMin, kRateMax) == 1.0); + CHECK(rateRatioFromNorm(rateNormFromRatio(0.5, kRateMin, kRateMax), kRateMin, kRateMax) == 0.5); + CHECK(rateRatioFromNorm(rateNormFromRatio(2.0, kRateMin, kRateMax), kRateMin, kRateMax) == 2.0); + for (int milli = 500; milli <= 2000; milli += 7) { + const double ratio = static_cast(milli) / 1000.0; + const double back = + rateRatioFromNorm(rateNormFromRatio(ratio, kRateMin, kRateMax), kRateMin, kRateMax); + CHECK(std::fabs(back - ratio) < 1e-14 * ratio); + if (!(std::fabs(back - ratio) < 1e-14 * ratio)) return; + } +} + +// The exact-unity detent is DERIVED from the bounds, not assumed to sit at centre. The shipped +// bounds are reciprocal so the two agree today, but they are a MEASURED range: re-measure them +// asymmetric and a detent pinned to 0.5 makes the map fold back on itself around centre. Run at +// a deliberately non-reciprocal pair, which is exactly the case the ratio-of-ratios and +// round-trip tests above would still have passed. +static void testRateDetentFollowsAsymmetricBoundsInsteadOfCentre() { + constexpr double kLo = 0.4; + constexpr double kHi = 3.0; // kLo * kHi == 1.2, so unity is NOT at 0.5 + const double unity = rateNormFromRatio(1.0, kLo, kHi); + CHECK(unity > 0.0 && unity < 1.0); + CHECK(std::fabs(unity - 0.5) > 0.01); // the case a 0.5 detent gets wrong + CHECK(rateRatioFromNorm(unity, kLo, kHi) == 1.0); // ...and unity is still EXACT there + + double prev = -1.0; + for (int i = 0; i <= 200000; ++i) { + const double v = rateRatioFromNorm(static_cast(i) / 200000.0, kLo, kHi); + CHECK(v >= prev); + if (v < prev) { std::printf(" asymmetric fold at i=%d\n", i); return; } + prev = v; + } + // That sweep steps OVER the detent rather than onto it, so walk its immediate neighbourhood + // too — a misplaced exact case shows up there and nowhere else. + for (int k = -8; k < 8; ++k) { + const double a = rateRatioFromNorm(unity + static_cast(k) * 1e-9, kLo, kHi); + const double b = rateRatioFromNorm(unity + static_cast(k + 1) * 1e-9, kLo, kHi); + CHECK(b >= a); + if (!(b >= a)) { std::printf(" detent fold at k=%d\n", k); return; } + } + // And the shipped reciprocal bounds still put unity at true knob centre: the general rule + // reproduces the special case rather than replacing it. + CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5); +} + +// Degenerate bounds are a caller bug, not a crash: the map collapses to unity. +static void testDegenerateRateBoundsCollapseToUnity() { + CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0); + CHECK(rateNormFromRatio(0.9, 2.0, 0.5) == 0.5); + CHECK(rateRatioFromNorm(0.3, 0.0, 2.0) == 1.0); +} + // --- the whole-unit snaps ------------------------------------------------------------------- static void testMillisecondSnap() { @@ -255,6 +363,36 @@ static void testSemitoneSnap() { kDepth) == 7.0); } +// Rate's unit is the semitone though it displays as a percent, so Shift lands on the 25 steps +// between the bounds — which is what puts an octave and a fifth under the hand. The detent and +// both ends are reached EXACTLY, so a snap cannot leave the knob a hair off its own endpoint. +static void testRateSemitoneSnap() { + CHECK(snapRateRatioToWholeSemitone(1.0) == 1.0); + CHECK(snapRateRatioToWholeSemitone(0.5) == 0.5); + CHECK(snapRateRatioToWholeSemitone(2.0) == 2.0); + CHECK(std::fabs(snapRateRatioToWholeSemitone(1.5) - std::exp2(7.0 / 12.0)) < 1e-15); + // Just off a step in each direction resolves back onto it. + CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 1.005) - + std::exp2(7.0 / 12.0)) < 1e-15); + CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 0.995) - + std::exp2(7.0 / 12.0)) < 1e-15); + // Within a quarter-semitone of unity snaps to unity, not to a neighbouring step. + CHECK(snapRateRatioToWholeSemitone(std::exp2(0.25 / 12.0)) == 1.0); + CHECK(snapRateRatioToWholeSemitone(0.0) == 1.0); // unusable input parks at unity + CHECK(snapRateRatioToWholeSemitone(-1.0) == 1.0); + // What the knob actually stores after a Shift-drag is the snapped norm mapped back through + // the taper — so the property that matters is that THAT value is still a whole semitone. + // Measured in semitones, which is the unit the criterion is stated in. + for (int st = -12; st <= 12; ++st) { + const double norm = + rateNormFromRatio(std::exp2(static_cast(st) / 12.0), kRateMin, kRateMax); + const double stored = rateRatioFromNorm(norm, kRateMin, kRateMax); + const double semis = 12.0 * std::log2(stored); + CHECK(std::fabs(semis - static_cast(st)) < 1e-9); + if (!(std::fabs(semis - static_cast(st)) < 1e-9)) return; + } +} + // The exponent snap reaches 1.0, the linear neutral — one snap from the dial's centre — and // clamps into curve_law's own domain rather than rounding to a zero that is not an exponent. static void testExponentSnap() { @@ -285,9 +423,17 @@ int main() { testEveryWholeSemitoneRoundTripsExactly(); testDegenerateThrowCollapsesToCentre(); + testRateEndpointsAndCentreAreExact(); + testRateIsLinearInSemitonesAcrossTheWholeTravel(); + testRateIsMonotone(); + testRateDefaultAndEndpointsRoundTripBitwise(); + testRateDetentFollowsAsymmetricBoundsInsteadOfCentre(); + testDegenerateRateBoundsCollapseToUnity(); + testMillisecondSnap(); testPercentSnap(); testSemitoneSnap(); + testRateSemitoneSnap(); testExponentSnap(); if (g_fail == 0) std::printf("param_taper: all tests passed\n"); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 3ab30ef..adc3efc 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -3070,12 +3070,14 @@ static void testPreserveStretchChangesDurationNotPitch() { CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0)); CHECK(approx(period(fast, 2000, 9000), srcPeriod, 8.0)); - // The non-tautology witness: VARISPEED is the engine that couples them. Reaching the same - // durations there costs exactly the pitch change Preserve refuses to make — so the three - // equal periods above are a property of the stretcher, not of the measurement. + // The non-tautology witness: VARISPEED is the engine that couples them. The SAME rate 0.5 + // reaches the same doubled duration there, and pays for it with exactly the octave Preserve + // refuses to drop — so the three equal periods above are a property of the stretcher, not of + // the measurement. std::size_t lifeVari = 0; const std::vector vari = run(0.5, PitchEngine::Varispeed, lifeVari); - CHECK(approx(static_cast(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed + CHECK(approx(static_cast(lifeVari), 48000.0, 400.0)); + CHECK(approx(period(vari, 2000, 9000), srcPeriod * 2.0, 16.0)); SampleData down = s; down.play.pitchEngine = PitchEngine::Varispeed; Voice vv; @@ -3091,6 +3093,373 @@ static void testPreserveStretchChangesDurationNotPitch() { CHECK(approx(period(variDown, 2000, 9000), srcPeriod * 2.0, 16.0)); // ...at half pitch } +// --- Rate, the Pitch offset and key-tracking compound into ONE read increment. --- + +// Proved by IDENTITY rather than by measurement: under Varispeed the three factors land in one +// multiply, so three different ways of asking for the same total ratio must render BYTE for +// BYTE the same. A per-sample stage added for either new control, or one of them applied at a +// different point in the chain, breaks this equality even where a measured pitch still looks +// right — which a period measurement alone would not catch. +static void testKeyTrackRateAndPitchOffsetResolveToOneMultiply() { + SampleData base = sineSample(20000, 100.0); + base.play.adsr = flatAdsr(); + base.play.pitchEngine = PitchEngine::Varispeed; + + const std::size_t n = 8000; + auto render = [&](int note, double rate, double offsetSemis) { + SampleData s = base; + s.play.playRate = rate; + s.play.pitchOffsetSemitones = offsetSemis; + Voice v; + v.start(note, 127, s, /*declickTakeover=*/false, rate); + std::vector out(n, 0.0f); + for (std::size_t i = 0; i < n; ++i) out[i] = v.renderFrame(); + return out; + }; + + // Three routes to a half-speed, octave-down read: through the keyboard, through Rate, and + // through the Pitch offset. + const std::vector viaNote = render(48, 1.0, 0.0); + const std::vector viaRate = render(60, 0.5, 0.0); + const std::vector viaOffset = render(60, 1.0, -12.0); + CHECK(hashStream(viaNote) == hashStream(viaRate)); + CHECK(hashStream(viaNote) == hashStream(viaOffset)); + // And they are not all trivially silent or all trivially unity — the route below differs. + CHECK(hashStream(viaNote) != hashStream(render(60, 1.0, 0.0))); + + // They MULTIPLY rather than accumulate anywhere else: an octave down at the keyboard and a + // doubled Rate cancel exactly, back to the untransposed read. + CHECK(hashStream(render(48, 2.0, 0.0)) == hashStream(render(60, 1.0, 0.0))); + // Same cancellation across the other pair, so no factor is privileged. + CHECK(hashStream(render(60, 2.0, -12.0)) == hashStream(render(60, 1.0, 0.0))); +} + +// Under PRESERVE the same three factors SPLIT: key-tracking and the Pitch offset drive the +// shifter's transpose, Rate drives duration alone. Asserted both ways round — the offset must +// move pitch WITHOUT moving duration, which is the mirror of the rate case beside it. +static void testPreserveRoutesRateToDurationAndTheOffsetToPitch() { + const std::int64_t w = 1024; + const std::size_t frames = 24000; + const double srcPeriod = 160.0; + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / srcPeriod)); + } + s.rootNote = 60; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + + auto run = [&](int note, double rate, double offsetSemis, std::size_t& life) { + SampleData local = s; + local.play.playRate = rate; + local.play.pitchOffsetSemitones = offsetSemis; + Voice v; + v.presizePreserveShifters(w); + v.start(note, 127, local, /*declickTakeover=*/false, rate); + std::vector out; + out.reserve(frames * 3); + life = 0; + for (std::size_t i = 0; i < frames * 3 && v.active(); ++i) { + out.push_back(v.renderFrame()); + ++life; + } + return out; + }; + auto period = [](const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + if (v[i - 1] <= 0.0f && v[i] > 0.0f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + + std::size_t lifeFlat = 0, lifeDown = 0; + const std::vector flat = run(60, 1.0, 0.0, lifeFlat); + const std::vector down = run(60, 1.0, -12.0, lifeDown); + // Duration is untouched by the offset — only the transpose moved. + CHECK(approx(static_cast(lifeFlat), 24000.0, 200.0)); + CHECK(approx(static_cast(lifeDown), 24000.0, 200.0)); + CHECK(approx(period(flat, 2000, 9000), srcPeriod, 8.0)); + CHECK(approx(period(down, 2000, 9000), srcPeriod * 2.0, 16.0)); + + // The offset and the keyboard reach the shifter through the SAME factor, so an octave down + // from either is the identical render. + std::size_t lifeNote = 0; + const std::vector viaNote = run(48, 1.0, 0.0, lifeNote); + CHECK(hashStream(viaNote) == hashStream(down)); + // …and Rate does not reach it at all: a rate change moves duration and leaves the period. + std::size_t lifeSlow = 0; + const std::vector slow = run(60, 0.5, 0.0, lifeSlow); + CHECK(approx(static_cast(lifeSlow), 48000.0, 400.0)); + CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0)); +} + +// The loop's AUDIBLE period scales with Rate while its stored frames — the marks the waveform +// draws — are never rewritten. The source is a ramp confined to the loop span, so the rendered +// stream is a sawtooth whose period IS the loop traversed once. +static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() { + constexpr std::int64_t kLoopStart = 4000; + constexpr std::int64_t kLoopEnd = 8000; + SampleData base; + base.frames.assign(20000, 0.0f); + for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) { + base.frames[static_cast(i)] = + static_cast(i - kLoopStart) / static_cast(kLoopEnd - kLoopStart); + } + base.rootNote = 60; + base.startFrame = kLoopStart; + base.loop = SampleLoop{true, kLoopStart, kLoopEnd}; + base.play.adsr = flatAdsr(); + base.play.pitchEngine = PitchEngine::Varispeed; + + // Output frames between successive mid-ramp crossings — the loop's audible period. Measured + // on the RISING half rather than on the seam: at a fractional read position the seam frame is + // interpolated across the wrap, so the drop arrives as two half-steps and an edge detector + // either misses it or counts it twice. The ramp crosses its midpoint exactly once per cycle. + auto sawPeriod = [](const std::vector& v) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = 1; i < v.size(); ++i) { + if (v[i - 1] <= 0.5f && v[i] > 0.5f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + + for (double rate : {1.0, 0.5, 2.0}) { + SampleData s = base; + s.play.playRate = rate; + Voice v; + v.start(60, 127, s, /*declickTakeover=*/false, rate); + std::vector out(30000, 0.0f); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame(); + CHECK(approx(sawPeriod(out), 4000.0 / rate, 2.0)); + // The stored span is a source-frame FACT: the engine reads it and never writes it, so + // the two waveform markers sit where they sat. + CHECK(s.loop.start == kLoopStart); + CHECK(s.loop.end == kLoopEnd); + CHECK(s.startFrame == kLoopStart); + } +} + +// Preserve's half of the loop claim, and it is the OPPOSITE of the Varispeed one — written down +// here because the obvious extension of the test above is WRONG. Preserve consumes the loop at +// `rate` source frames per output frame, so the TRAVERSAL scales (the feed-side witness in +// testPreserveStretchLoopsTheSourceSpan measures that directly); what the listener hears does +// not, because holding the source's period while its duration changes is the definition of the +// engine. Measured with a ring long enough to hold the whole loop, so the reading is the design +// property rather than splice cadence — at shorter rings the same fixture measured 3064 and 4130 +// frames at rate 0.5 (windows 1024 and 2048), neither of which is the 8000 a scaling period +// would give either. +static void testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal() { + constexpr std::int64_t kLoopStart = 4000; + constexpr std::int64_t kLoopEnd = 8000; + SampleData base; + base.frames.assign(20000, 0.0f); + for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) { + base.frames[static_cast(i)] = + static_cast(i - kLoopStart) / static_cast(kLoopEnd - kLoopStart); + } + base.rootNote = 60; + base.startFrame = kLoopStart; + base.loop = SampleLoop{true, kLoopStart, kLoopEnd}; + base.play.adsr = flatAdsr(); + base.play.pitchEngine = PitchEngine::Preserve; + + auto sawPeriod = [](const std::vector& v) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = 1; i < v.size(); ++i) { + if (v[i - 1] <= 0.5f && v[i] > 0.5f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + + for (double rate : {1.0, 0.5, 2.0}) { + SampleData s = base; + s.play.playRate = rate; + Voice v; + v.presizePreserveShifters(8192); // > the 4000-frame loop + v.start(60, 127, s, /*declickTakeover=*/false, rate); + std::vector out(40000, 0.0f); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame(); + const double period = sawPeriod(out); + CHECK(approx(period, 4000.0, 40.0)); + if (!approx(period, 4000.0, 40.0)) std::printf(" rate %.2f period %.1f\n", rate, period); + // And the marks the waveform draws are source-frame FACTS the engine only ever reads. + CHECK(s.loop.start == kLoopStart); + CHECK(s.loop.end == kLoopEnd); + CHECK(s.startFrame == kLoopStart); + } +} + +// The other half of the same rule, which nothing asserted: a drawn contour is a pure function of +// NORMALIZED sample position, so it follows the read head and its wall-clock shape scales by +// 1/rate — under BOTH engines, since both advance that head at the rate. Measured as the output +// frame the contour's own half-way point arrives on, which is what a listener hears move. +static void testADrawnContourScalesWithRateInBothEngines() { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + double atUnity = 0.0; + for (double rate : {1.0, 0.5, 2.0}) { + SampleData s = dcSample(24000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = eng; + s.play.playRate = rate; + s.play.ampSpline.mode = EnvMode::Spline; + s.play.ampSpline.contour = VelocityCurve::linear(); // 0 -> 1 across the sample + Voice v; + v.presizePreserveShifters(1024); + v.start(60, 127, s, /*declickTakeover=*/false, rate); + double halfway = 0.0; + for (std::size_t i = 0; i < 80000 && v.active(); ++i) { + const double y = static_cast(v.renderFrame()); + if (halfway == 0.0 && y > 0.5) halfway = static_cast(i); + } + CHECK(halfway > 0.0); + if (rate == 1.0) atUnity = halfway; + // 12000 source frames in at unity; twice as many output frames at half rate. + else CHECK(approx(halfway, atUnity / rate, atUnity * 0.02)); + if (rate != 1.0 && !approx(halfway, atUnity / rate, atUnity * 0.02)) { + std::printf(" eng %d rate %.2f: halfway %.0f, wanted %.0f\n", + static_cast(eng), rate, halfway, atUnity / rate); + } + } + } +} + +// Pitch is the same multiply as Rate under Varispeed, so the same rule binds it: a staged stage +// time is OF THE PERFORMANCE and does not scale. The AHD is the case that can go wrong, since it +// is evaluated at the SOURCE offset — which a Pitch offset advances faster or slower. Under +// Preserve the offset never touches the read, so the same attack lands on the same frame there +// for a different reason; asserted in both so the compensation cannot be applied to the wrong +// engine. Key-tracking is deliberately NOT compensated, and the last block pins that too. +static void testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed() { + constexpr std::int64_t kAttack = 2000; + SampleData base = dcSample(48000); + base.play.playMode = PlayMode::Trigger; + base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + + const auto attackFrame = [](const SampleData& s, int note) { + Voice v; + v.presizePreserveShifters(1024); + v.start(note, 127, s, /*declickTakeover=*/false, s.play.playRate); + for (std::size_t i = 0; i < 200000 && v.active(); ++i) { + if (static_cast(v.renderFrame()) > 0.99) return static_cast(i); + } + return -1.0; + }; + + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + for (double semis : {-12.0, -5.0, 0.0, 7.0, 12.0}) { + SampleData s = base; + s.play.pitchEngine = eng; + s.play.pitchOffsetSemitones = semis; + const double got = attackFrame(s, 60); + CHECK(approx(got, static_cast(kAttack), 40.0)); + if (!approx(got, static_cast(kAttack), 40.0)) { + std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n", + static_cast(eng), semis, got); + } + } + } + + // Key-tracking stays UNCOMPENSATED on purpose — it is a shipped sound, and compensating it + // would move every note off the root. An octave up therefore completes the attack in half + // the output frames, which is exactly the behaviour Pitch above does not have. + SampleData vari = base; + vari.play.pitchEngine = PitchEngine::Varispeed; + CHECK(approx(attackFrame(vari, 72), static_cast(kAttack) / 2.0, 40.0)); +} + +// --- The Varispeed null case, baselined so the NEXT track's claim is measured. --- +// Unlike the Preserve hashes above, these were captured from THIS commit rather than witnessed +// against the pre-track one, and that difference is the whole reason the comment says so: the +// pre-track equality is proved structurally instead, and cheaply — at Rate 100 % and Pitch 0 st +// both new factors of recomputeBaseRatio's product are EXACTLY 1.0 (semitoneRatio short-circuits +// at zero; the clamp returns 1.0 for 1.0), and multiplying a double by 1.0 is bit-exact, so the +// read increment is the pre-track engine's own. What these constants add is a witness for the +// track AFTER this one. A change here is a change to what every already-saved project sounds +// like — re-derive the cause before re-baselining. +static void testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline() { + const std::size_t n = 6000; + struct Case { int note; bool stereo; bool loop; std::uint64_t hashL; std::uint64_t hashR; }; + const Case cases[] = { + {60, false, false, 5964955069002935931ull, 0ull}, // on root: unity read + {67, false, false, 134881748704183217ull, 0ull}, // +7 st + {55, false, false, 11914283967735558216ull, 0ull}, // -5 st + {67, true, true, 11674273643338193955ull, 15241091931688620298ull}, // stereo + loop + }; + for (const Case& c : cases) { + SampleData s = stretchProbeSample(4000, c.stereo); + s.play.pitchEngine = PitchEngine::Varispeed; + if (c.loop) { + s.loop.hasLoop = true; + s.loop.start = 1200; + s.loop.end = 3600; + s.loopCrossfadeFrames = 256; + } + std::vector l(n), r(c.stereo ? n : 0); + renderVoice(s, c.note, /*rate=*/1.0, /*window=*/2205, c.stereo, l, r); + const std::uint64_t hl = hashStream(l); + CHECK(hl == c.hashL); + if (hl != c.hashL) std::printf(" varispeed note %d L hash %lluull\n", c.note, hl); + if (c.stereo) { + const std::uint64_t hr = hashStream(r); + CHECK(hr == c.hashR); + if (hr != c.hashR) std::printf(" varispeed note %d R hash %lluull\n", c.note, hr); + } + } +} + +// The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a +// staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go +// wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames +// are fitted to that rate at note-on. Measured as the OUTPUT frame the attack completes on. +static void testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes() { + constexpr std::int64_t kAttack = 2000; + SampleData base = dcSample(24000); + base.play.playMode = PlayMode::Trigger; + base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + std::size_t lifeAtUnity = 0; + for (double rate : {1.0, 2.0, 0.5}) { + SampleData s = base; + s.play.pitchEngine = eng; + s.play.playRate = rate; + Voice v; + v.presizePreserveShifters(1024); + v.start(60, 127, s, /*declickTakeover=*/false, rate); + std::size_t life = 0, reachedFull = 0; + for (std::size_t i = 0; i < 80000 && v.active(); ++i) { + const double y = static_cast(v.renderFrame()); + if (reachedFull == 0 && y > 0.99) reachedFull = i; + ++life; + } + // The attack is wall clock: the same OUTPUT frame at every rate. + CHECK(approx(static_cast(reachedFull), static_cast(kAttack), 40.0)); + // …while the play span itself is source frames, so the note's length DOES scale. + if (rate == 1.0) lifeAtUnity = life; + else CHECK(approx(static_cast(life), + static_cast(lifeAtUnity) / rate, + static_cast(lifeAtUnity) * 0.02)); + } + } +} + // --- The onset is a regression surface: no added latency at ANY rate. --- static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() { const std::int64_t w = 2048; @@ -3440,6 +3809,14 @@ int main() { testPreserveUnityRateIsBitIdenticalToTheShippedRead(); testSourcePeriodChangesTheRenderedStream(); testPreserveStretchChangesDurationNotPitch(); + testKeyTrackRateAndPitchOffsetResolveToOneMultiply(); + testPreserveRoutesRateToDurationAndTheOffsetToPitch(); + testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames(); + testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal(); + testADrawnContourScalesWithRateInBothEngines(); + testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed(); + testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline(); + testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchLoopsTheSourceSpan(); testPreserveStretchThirtyTwoVoicesHoldUp();