PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16

This commit is contained in:
2026-08-02 05:34:33 -04:00
parent ef59265e7a
commit 248f2f3842
32 changed files with 1098 additions and 163 deletions
+1 -1
View File
@@ -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)
+6 -5
View File
@@ -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.
@@ -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;
}
+8
View File
@@ -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.
+1 -1
View File
@@ -107,7 +107,7 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& 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
+9
View File
@@ -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
+41 -25
View File
@@ -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<double>(postStart)
: static_cast<double>(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<double>(postStart) / readRate
: static_cast<double>(postStart);
pitchEnv_.configure(static_cast<std::int64_t>(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<double>(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);
+55 -23
View File
@@ -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<double>(velocity)) * kVelocityPitchRangeSemitones;
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
return semitoneRatio(curve.eval(static_cast<double>(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<std::int64_t>(static_cast<double>(a.attackFrames) * stretchRate_ + 0.5);
out.decayFrames =
static_cast<std::int64_t>(static_cast<double>(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;
+5 -1
View File
@@ -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_++);
}
+18 -4
View File
@@ -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 <cstdint>
@@ -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
@@ -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<std::uint8_t>& out, const InstrumentParams& p)
out.push_back(static_cast<std::uint8_t>(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{};
+1 -1
View File
@@ -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 <cstdint>
+4
View File
@@ -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
+2
View File
@@ -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);
+3 -1
View File
@@ -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
+23 -13
View File
@@ -23,11 +23,16 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
const bool trigger = (playMode == PlayMode::Trigger);
std::vector<DeckGroupDesc> 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<DeckGroupDesc> 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<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
return (paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount))
? deckParamCommit(static_cast<DeckParam>(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
+37 -14
View File
@@ -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<DeckGroupDesc> 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:
+22
View File
@@ -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);
+7
View File
@@ -8,6 +8,7 @@
#include <cstddef>
#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
+33
View File
@@ -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
+18
View File
@@ -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
+4 -2
View File
@@ -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
+10
View File
@@ -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:
+3 -1
View File
@@ -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;
+4 -1
View File
@@ -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() {
+2 -2
View File
@@ -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;
+149 -40
View File
@@ -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<std::uint8_t>& out, int quarterExponent,
note::DivisionModifier modifier) {
legacy::u32v(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(quarterExponent)));
@@ -811,9 +820,15 @@ static void putLimiterTail(std::vector<std::uint8_t>& out, bool enabled) {
legacy::u8v(out, enabled ? 1 : 0);
}
static void putRateTail(std::vector<std::uint8_t>& out, double rate, double pitchOffset) {
legacy::f64v(out, rate);
legacy::f64v(out, pitchOffset);
}
static void putDefaultTrailingTails(std::vector<std::uint8_t>& 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t>(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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint32_t>(static_cast<std::int32_t>(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<std::uint8_t> 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<double>::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<double>::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<std::uint8_t> 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;
+68 -24
View File
@@ -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<int>(DeckParam::kCount); ++i) {
const DeckParam p = static_cast<DeckParam>(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<int>(DeckParam::kFilterCutoff)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack)));
const auto knob = [](DeckParam p) {
return liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(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<int>(DeckParam::kTrigAttack)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigDecayCurve)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigLength)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(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<int>(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<int>(DeckParam::kFilterCutoff)));
CHECK(liveCommitFor(LiveDragKind::kOther, static_cast<int>(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<DeckGroupDesc> 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<int>(DeckParam::kKeyTrack));
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
CHECK(pitch->cellIds[2] == static_cast<int>(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<DeckGroupDesc> 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();
+86
View File
@@ -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<double>(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();
+132
View File
@@ -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<float>(static_cast<double>(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<AudioSample> renderPreserveCapable(SampleData& s, LiveParams& block,
const LiveValues* changed, int changeAfter,
int note) {
s.live = &block;
block.publish(foldLive(s.play));
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(note, 100);
std::vector<AudioSample> 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<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, 60);
const std::vector<AudioSample> 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 = &block;
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<AudioSample> 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<double>(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<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, kTestNote);
const std::vector<AudioSample> 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<double>(swept[i]) -
static_cast<double>(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");
+112
View File
@@ -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<double>(i) * step, kRateMin, kRateMax);
const double hi = rateRatioFromNorm(static_cast<double>(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<double>(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<double>(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<double>(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<double>(st)) < 1e-9);
if (!(std::fabs(semis - static_cast<double>(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");
+204 -4
View File
@@ -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<AudioSample> vari = run(0.5, PitchEngine::Varispeed, lifeVari);
CHECK(approx(static_cast<double>(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed
CHECK(approx(static_cast<double>(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<AudioSample> 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<AudioSample> viaNote = render(48, 1.0, 0.0);
const std::vector<AudioSample> viaRate = render(60, 0.5, 0.0);
const std::vector<AudioSample> 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<float>(std::sin(2.0 * kPi * static_cast<double>(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<AudioSample> 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<AudioSample>& 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<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
std::size_t lifeFlat = 0, lifeDown = 0;
const std::vector<AudioSample> flat = run(60, 1.0, 0.0, lifeFlat);
const std::vector<AudioSample> down = run(60, 1.0, -12.0, lifeDown);
// Duration is untouched by the offset — only the transpose moved.
CHECK(approx(static_cast<double>(lifeFlat), 24000.0, 200.0));
CHECK(approx(static_cast<double>(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<AudioSample> 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<AudioSample> slow = run(60, 0.5, 0.0, lifeSlow);
CHECK(approx(static_cast<double>(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<std::size_t>(i)] =
static_cast<float>(i - kLoopStart) / static_cast<float>(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<AudioSample>& 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<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(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<AudioSample> 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<double>(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<double>(reachedFull), static_cast<double>(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<double>(life),
static_cast<double>(lifeAtUnity) / rate,
static_cast<double>(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();