Merge Γ-W2-T1: Rate and Pitch compound into one read increment, on a three-state commit predicate and payload v16

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