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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_++);
|
||||
}
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user