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:
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,22 +29,36 @@ 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.
|
||||||
play.pitchVelocityCurve.eval(velocity));
|
//
|
||||||
if (play.pitchEnv.enabled) {
|
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
|
||||||
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
|
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
|
||||||
// the staged AHD only ever travels between 0 and the peak.
|
// rate alone, which is the only sense in which the two are decoupled there.
|
||||||
down += play.pitchSpline.mode == EnvMode::Spline
|
//
|
||||||
? -std::fabs(play.pitchEnv.peakSemitones)
|
// A Gate release is ticked per output frame, so neither term touches it.
|
||||||
: (std::min)(0.0, play.pitchEnv.peakSemitones);
|
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));
|
||||||
|
// 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) {
|
||||||
|
// 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.
|
||||||
|
down += play.pitchSpline.mode == EnvMode::Spline
|
||||||
|
? -std::fabs(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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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_++);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ----------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.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 = █
|
||||||
|
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 = █
|
||||||
|
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 = █
|
||||||
|
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");
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user