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 a852599..0fdadbe 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/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..eb0e5cc 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -53,16 +53,26 @@ 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. + const bool preserveRead = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); + rateRatio_ = preserveRead ? 1.0 : stretchRate_; + recomputeBaseRatio(); // 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,22 +129,19 @@ 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_; + // and this envelope counts OUTPUT frames (envelopes.h), so the span has to be divided by the + // rate the read head consumes source at — baseRatio_ under Varispeed, the stretch rate under + // Preserve — or a transposed (or re-rated) note's envelope outruns the note it shapes. + // Divides by baseRatio_ alone under Varispeed, though the actual read rate is baseRatio_ x + // envFactor — a deep pitch envelope makes that a first-order approximation, not exact. + const double readRate = preserveRead ? stretchRate_ : baseRatio_; + const double pitchSpan = (readRate > 0.0) ? static_cast(postStart) / readRate + : static_cast(postStart); pitchEnv_.configure(static_cast(pitchSpan + 0.5), p.pitchEnv); pitchEnv_.noteOn(); @@ -169,7 +176,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,16 +270,25 @@ 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 rateFittedAhd converts a live AHD against, 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); if (snap) { if (gate) env_.snapLive(live.adsr); - else ampAhd_.snapLive(live.ampAhd); + else ampAhd_.snapLive(rateFittedAhd(live.ampAhd)); pitchEnv_.snapLive(live.pitchEnv); } else { if (gate) env_.applyLive(live.adsr); - else ampAhd_.applyLive(sourceOffset(), live.ampAhd); + else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd)); pitchEnv_.applyLive(live.pitchEnv); } + // 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. + pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones); + recomputeBaseRatio(); // 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_, // which already glides through rModAmount_'s live ramp regardless of spline state (below), @@ -283,10 +299,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; @@ -328,8 +344,8 @@ void Voice::retune(int note) { // 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. // 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..7c119ee 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,34 @@ 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_; + } + + // A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the + // sustain-less envelopes are evaluated in (sourceOffset()). Rate 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. The Varispeed PITCH coupling is deliberately NOT + // compensated here: it predates Rate and is the shipped behaviour. Rate 1.0 returns the + // argument untouched, which is what keeps the unity render bit-identical. + AhdParams rateFittedAhd(const AhdParams& a) const { + if (stretchRate_ == 1.0) return a; + AhdParams out = a; + out.attackFrames = + static_cast(static_cast(a.attackFrames) * stretchRate_ + 0.5); + out.decayFrames = + static_cast(static_cast(a.decayFrames) * stretchRate_ + 0.5); + return out; + } + // 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 +229,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 +569,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 +675,10 @@ 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 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..60081e9 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -263,6 +263,30 @@ 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 gets finiteness only, because its range is the +// stretcher's and clampStretchRate is the one authority on it — a second range test here is +// exactly the second clamp that could disagree. The offset gets a real range test, 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) && rate > 0.0) p.play.playRate = 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 +525,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 +583,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..cec5549 100644 --- a/src/core/instrument/ui/param_taper.cpp +++ b/src/core/instrument/ui/param_taper.cpp @@ -32,6 +32,10 @@ double depthSpan(double maxSemitones) { return std::log1p(maxSemitones / kDepthOffsetSemitones); } +double rateSpanOctaves(double minRatio, double maxRatio) { + return std::log2(maxRatio / minRatio); +} + } // namespace double timeNormFromSeconds(double seconds) { @@ -66,6 +70,27 @@ 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; + if (ratio == 1.0) return 0.5; // the centre detent is EXACT, so unity persists as unity + 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 == 0.5) 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 + // centre 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 +108,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..a0f7f22 100644 --- a/src/core/instrument/ui/param_taper.h +++ b/src/core/instrument/ui/param_taper.h @@ -73,6 +73,20 @@ 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 norm 0.5 whenever the bounds bracket it, which is this control's whole +// preimage obligation — see rateRatioFromNorm for why it carries no output quantum. +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 +97,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_component_state_io.cpp b/tests/test_component_state_io.cpp index 276734c..5bc8ae9 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -453,7 +453,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 +551,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 +601,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 +616,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 +802,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 +820,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 +863,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 +911,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 +962,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 +1059,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 +1270,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 +1295,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 +1435,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 +1454,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 +1482,98 @@ 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); +} + +// Neither field has a clamp of its own downstream that could rescue a corrupt blob: the rate +// multiplies a read increment (the engine's own clampStretchRate is the one authority on its +// RANGE, so the codec only refuses the unusable) and the offset feeds a 2^(x/12) whose result +// reaches a per-sample cast. Both degrade to their neutral rather than through. +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}, + {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 +2186,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..ab2a3d4 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 @@ -342,6 +425,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..7705d36 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -720,6 +720,136 @@ 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); + } +} + // --- What stays latched at note-on ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { @@ -859,6 +989,8 @@ int main() { testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote(); testEveryLiveFilterControlMovesTheSoundingNote(); testOneBlockServesTwoIndependentObservers(); + testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); + testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); 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..61b35d3 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,76 @@ 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; + } +} + +// 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 +330,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 +390,16 @@ int main() { testEveryWholeSemitoneRoundTripsExactly(); testDegenerateThrowCollapsesToCentre(); + testRateEndpointsAndCentreAreExact(); + testRateIsLinearInSemitonesAcrossTheWholeTravel(); + testRateIsMonotone(); + testRateDefaultAndEndpointsRoundTripBitwise(); + 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..3294b89 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,200 @@ 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); + } +} + +// 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 +3636,10 @@ int main() { testPreserveUnityRateIsBitIdenticalToTheShippedRead(); testSourcePeriodChangesTheRenderedStream(); testPreserveStretchChangesDurationNotPitch(); + testKeyTrackRateAndPitchOffsetResolveToOneMultiply(); + testPreserveRoutesRateToDurationAndTheOffsetToPitch(); + testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames(); + testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchLoopsTheSourceSpan(); testPreserveStretchThirtyTwoVoicesHoldUp();