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
+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;