Bake window derives itself: %-knob fold, declick pad, Gate held to exhaustion, preview velocity; Hold is the one knob a loop needs
This commit is contained in:
@@ -12,8 +12,8 @@ subdirectories:
|
|||||||
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
|
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
|
||||||
play-span formula).
|
play-span formula).
|
||||||
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
|
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
|
||||||
resolution, and anchored start/end offsets — the one record and resolver a
|
resolution, and anchored start/end offsets — the one record and resolver the offline bake
|
||||||
capture-signal popup and the offline bake read from, so they cannot diverge.
|
and any future editor of it read from, so they cannot diverge.
|
||||||
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
|
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
|
||||||
window, the offline render over a voice engine built for that render alone, and the
|
window, the offline render over a voice engine built for that render alone, and the
|
||||||
ratified post-bake reset. See `bake/CLAUDE.md`.
|
ratified post-bake reset. See `bake/CLAUDE.md`.
|
||||||
@@ -295,17 +295,18 @@ 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…v13), 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). 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…v14), 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. 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.
|
||||||
- `trigger_seam` — the shared Trigger play-SPAN formula: how the stored %-length becomes the source-frame span the voice plays and the overlay draws over, threading `startFrame` correctly. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
- `trigger_seam` — the shared Trigger play-SPAN formula: how the stored %-length becomes the source-frame span the voice plays, the overlay draws over and the bake's window holds, threading `startFrame` correctly. Home to `effectiveLengthFraction`, the spline fold every one of those three must apply — the %-knob goes inert but stays STORED while a contour is drawn, so a raw read of it silently shortens whatever reads it. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
||||||
|
|
||||||
### `ui/`
|
### `ui/`
|
||||||
|
|
||||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included: the run is right-anchored, so laying a member out conditionally would slide its neighbours out from under the pointer. Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
||||||
|
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
||||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||||
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||||
|
|||||||
@@ -23,10 +23,23 @@ decision about what the render made obsolete.
|
|||||||
even a pathological envelope cannot run past the window.
|
even a pathological envelope cannot run past the window.
|
||||||
- **The whole signal chain is printed, master gain included** — the gain multiply in
|
- **The whole signal chain is printed, master gain included** — the gain multiply in
|
||||||
`bake_render.cpp` carries the argument for why.
|
`bake_render.cpp` carries the argument for why.
|
||||||
- **A degenerate or unholdable window is refused, not rendered.** `planBake` returns nullopt
|
- **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a
|
||||||
for a collapsed window, a non-positive rate, a window that rounds to no frames, and one
|
collapsed window, a non-positive rate, a window that rounds to no frames, and one past
|
||||||
past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
|
`kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
|
||||||
seconds→frames narrowing is undefined long before the allocation would fail.
|
seconds→frames narrowing is undefined long before the allocation would fail. The refusal
|
||||||
|
carries a `BakeRefusal` naming WHICH: past-the-ceiling is a real sound that will not fit,
|
||||||
|
which reads to the user as a different sentence than an empty window.
|
||||||
|
- **The window derives itself, and Hold is the one exception.** Trigger derives from the play
|
||||||
|
span; Gate over an active sustain loop takes the user's Hold, because a loop sounds for as
|
||||||
|
long as it is held and no derivation can supply a duration; Gate WITHOUT one derives from
|
||||||
|
source exhaustion, since the read head frees the voice whether or not the gate is down.
|
||||||
|
`bakeWindowNeedsHold` is the predicate, and it reads the ENGINE's loop fold rather than the
|
||||||
|
loop fields, so the control that collects Hold cannot appear for a loop the voice refuses.
|
||||||
|
- **Trailing silence is free; truncation is not.** Every derivation rounds outward — the Gate
|
||||||
|
note rounds UP to a programmable length that outlasts the source, the Varispeed bound takes
|
||||||
|
the deepest reachable offset, and every path is padded by the voice's terminal declick ramp
|
||||||
|
(`kDeclickFrames`, unconditionally — not branched on the pitch engine that has the ramp
|
||||||
|
today). Judge any change to this module against that asymmetry.
|
||||||
- **The reset's survive list is written out; everything else defaults.** `resetAfterBake`
|
- **The reset's survive list is written out; everything else defaults.** `resetAfterBake`
|
||||||
starts from a default-constructed parameter set and copies back only the mapping facts.
|
starts from a default-constructed parameter set and copies back only the mapping facts.
|
||||||
A parameter added later therefore resets by default — the safe direction, since
|
A parameter added later therefore resets by default — the safe direction, since
|
||||||
@@ -38,10 +51,10 @@ decision about what the render made obsolete.
|
|||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
- `bake_plan` — `defaultBakeProgram` (the program a bake uses until the capture-signal popup
|
- `bake_plan` — `defaultBakeProgram` (the whole programmed note, DERIVED from the dialed
|
||||||
ships; its end offset is DERIVED from the dialed sound, never constant), `BakePlan` (the
|
sound: its note length as well as its end offset), `bakeWindowNeedsHold`, `BakePlan` (the
|
||||||
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
|
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
|
||||||
`planBake`, the one `ResolvedNote` + rate -> frames resolution.
|
`planBake`, the one `ResolvedNote` + rate -> frames resolution, answering a `PlannedBake`.
|
||||||
- `bake_render` — `BakeAudio` and `renderBake`: the programmed note through the sample's
|
- `bake_render` — `BakeAudio` and `renderBake`: the programmed note through the sample's
|
||||||
own voice path, summed into an interleaved buffer at the source's own channel count.
|
own voice path, summed into an interleaved buffer at the source's own channel count.
|
||||||
- `bake_reset` — `BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
|
- `bake_reset` — `BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
|
||||||
@@ -52,10 +65,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 Trigger window bounds the Varispeed read stretch, it does not
|
- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch
|
||||||
model it.** A downward pitch offset makes the read head take longer to cross the play
|
offset makes the read head take longer to cross its span, so the window is scaled by the
|
||||||
span, so the window is scaled by the deepest downward offset the voice can reach — an
|
deepest downward offset the voice can reach — a shallower excursion leaves trailing silence
|
||||||
upper bound, so a shallower excursion leaves trailing silence in the file. The
|
in the file. Both the Trigger span and the Gate exhaustion length take it.
|
||||||
capture-signal popup is where a user sets the window exactly.
|
- **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
|
||||||
|
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
|
||||||
- The render's channel count is the loaded `SampleData`'s, which is already the instance's
|
- The render's channel count is the loaded `SampleData`'s, which is already the instance's
|
||||||
channel-mode decision — a mono-mode instance bakes mono, and that is faithful, not a fold.
|
channel-mode decision — a mono-mode instance bakes mono, and that is faithful, not a fold.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# The default program's window is derived from the DIALED sound, so the plan reads the
|
# The default program's window is derived from the DIALED sound, so the plan reads the
|
||||||
# engine's value layer (sampler_core) and the one Trigger span formula (trigger_seam).
|
# engine's value layer, its loop fold and its declick length (all sampler_core), the one
|
||||||
|
# Trigger span formula (trigger_seam), and the note-length ladder (via note_program).
|
||||||
reasampler_pure_library(bake_plan
|
reasampler_pure_library(bake_plan
|
||||||
SOURCES bake_plan.cpp
|
SOURCES bake_plan.cpp
|
||||||
LINK PUBLIC note_program sampler_core trigger_seam)
|
LINK PUBLIC note_program sampler_core trigger_seam)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
|
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold)
|
||||||
|
#include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length)
|
||||||
|
#include "core/instrument/map/trigger_seam.h" // the one Trigger span formula + %-fold
|
||||||
|
|
||||||
namespace reasampler::instrument::bake {
|
namespace reasampler::instrument::bake {
|
||||||
|
|
||||||
@@ -28,8 +30,8 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
|
|||||||
|
|
||||||
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
|
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
|
||||||
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
|
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
|
||||||
// stretches how long Trigger's source span takes to play. Preserve decouples the two, and a
|
// stretches how long the source takes to play out. Preserve decouples the two, and a Gate
|
||||||
// Gate release is ticked per output frame, so neither is affected.
|
// release is ticked per output frame, so neither is affected.
|
||||||
double downwardSemitones(const PlayParams& play, int velocity) {
|
double downwardSemitones(const PlayParams& play, int velocity) {
|
||||||
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
|
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
|
||||||
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
|
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
|
||||||
@@ -44,13 +46,42 @@ double downwardSemitones(const PlayParams& play, int velocity) {
|
|||||||
return down;
|
return down;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
|
||||||
|
// rather than starting a voice already off the end.
|
||||||
|
std::int64_t effectiveStart(const SampleData& dialed) {
|
||||||
|
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
|
||||||
|
const std::int64_t start = dialed.startFrame;
|
||||||
|
return (start < 0 || start >= frameCount) ? 0 : start;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||||
|
std::int64_t frameCount) {
|
||||||
|
// resolveLoop already refuses a non-Gate voice, so this is exactly "Gate over a loop the
|
||||||
|
// read path will honour" — the engine's decision, not a second reading of the fields.
|
||||||
|
return engine::loop::resolveLoop(loop, crossfadeFrames, frameCount,
|
||||||
|
mode == PlayMode::Gate)
|
||||||
|
.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bakeWindowNeedsHold(const SampleData& dialed) {
|
||||||
|
return bakeWindowNeedsHold(dialed.play.playMode, dialed.loop, dialed.loopCrossfadeFrames,
|
||||||
|
static_cast<std::int64_t>(dialed.frames.size()));
|
||||||
|
}
|
||||||
|
|
||||||
NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
||||||
note::Tempo tempo) {
|
note::Tempo tempo, note::Division hold,
|
||||||
NoteProgram p; // 1/4 straight, velocity 100, capture opening at note-on
|
note::Velocity velocity) {
|
||||||
|
NoteProgram p; // 1/4 straight, capture opening at note-on
|
||||||
|
p.velocity = velocity;
|
||||||
if (renderSampleRate <= 0) return p;
|
if (renderSampleRate <= 0) return p;
|
||||||
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 std::int64_t start = effectiveStart(dialed);
|
||||||
|
const double stretch =
|
||||||
|
std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
|
||||||
|
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
|
||||||
|
|
||||||
double endOffsetSeconds = 0.0;
|
double endOffsetSeconds = 0.0;
|
||||||
if (dialed.play.playMode == PlayMode::Trigger) {
|
if (dialed.play.playMode == PlayMode::Trigger) {
|
||||||
@@ -58,25 +89,37 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
|||||||
// play span's end, which has nothing to do with the note's length — so the end
|
// play span's end, which has nothing to do with the note's length — so the end
|
||||||
// offset is whatever is left after the note, positive or negative.
|
// offset is whatever is left after the note, positive or negative.
|
||||||
const std::int64_t span = map::triggerPlayLength(
|
const std::int64_t span = map::triggerPlayLength(
|
||||||
dialed.play.trigger.lengthFraction,
|
map::effectiveLengthFraction(dialed.play), frameCount, start);
|
||||||
static_cast<std::int64_t>(dialed.frames.size()), dialed.startFrame);
|
|
||||||
const double stretch = std::pow(
|
|
||||||
2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
|
|
||||||
endOffsetSeconds = static_cast<double>(span) / rate * stretch -
|
endOffsetSeconds = static_cast<double>(span) / rate * stretch -
|
||||||
tempo.beatsToSeconds(note::divisionBeats(p.length));
|
tempo.beatsToSeconds(note::divisionBeats(p.length));
|
||||||
|
} else if (bakeWindowNeedsHold(dialed)) {
|
||||||
|
// The loop cycles for as long as the note is held, so the hold IS the length, and the
|
||||||
|
// release is the one stage that runs after note-off.
|
||||||
|
p.length = hold;
|
||||||
|
endOffsetSeconds = releaseSeconds;
|
||||||
} else {
|
} else {
|
||||||
// Gate: the release is the one stage that runs after note-off, so it is exactly
|
// Gate with no loop: the read head runs off the source and frees the voice whether or
|
||||||
// what the window has to hold past it.
|
// not the gate is still down, so the maximal sound is the whole post-start span held.
|
||||||
endOffsetSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
|
// Rounding the note UP to a programmable length that outlasts it costs nothing — the
|
||||||
|
// voice is already gone by then — while a shorter note releases mid-attack.
|
||||||
|
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - start);
|
||||||
|
const double exhaustSeconds = static_cast<double>(postStart) / rate * stretch;
|
||||||
|
p.length = note::shortestDivisionAtLeast(tempo.secondsToBeats(exhaustSeconds));
|
||||||
|
endOffsetSeconds = releaseSeconds;
|
||||||
}
|
}
|
||||||
|
// The voice rings its last output out over kDeclickFrames instead of hard-cutting it, and
|
||||||
|
// that ramp starts where the derivations above end. Added on every path, not just the
|
||||||
|
// Preserve one that has the ramp today: trailing silence is free, a hard cut is not.
|
||||||
|
endOffsetSeconds += static_cast<double>(kDeclickFrames) / rate;
|
||||||
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
|
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
|
PlannedBake planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) {
|
||||||
int rootNote) {
|
const PlannedBake empty{std::nullopt, BakeRefusal::EmptyWindow};
|
||||||
if (resolved.windowCollapsed) return std::nullopt;
|
const PlannedBake tooLong{std::nullopt, BakeRefusal::PastFrameCeiling};
|
||||||
if (sampleRate <= 0) return std::nullopt;
|
if (resolved.windowCollapsed) return empty;
|
||||||
|
if (sampleRate <= 0) return empty;
|
||||||
|
|
||||||
// The render starts at whichever comes first, note-on or the capture opening. A POSITIVE
|
// The render starts at whichever comes first, note-on or the capture opening. A POSITIVE
|
||||||
// start offset is legal and means the capture opens after the note — so the head is
|
// start offset is legal and means the capture opens after the note — so the head is
|
||||||
@@ -86,22 +129,22 @@ std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
|
|||||||
BakePlan plan;
|
BakePlan plan;
|
||||||
plan.sampleRate = sampleRate;
|
plan.sampleRate = sampleRate;
|
||||||
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
|
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
|
||||||
return std::nullopt;
|
return tooLong;
|
||||||
if (plan.totalFrames <= 0) return std::nullopt;
|
if (plan.totalFrames <= 0) return empty;
|
||||||
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
|
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
|
||||||
plan.leadInFrames))
|
plan.leadInFrames))
|
||||||
return std::nullopt;
|
return tooLong;
|
||||||
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return std::nullopt;
|
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return tooLong;
|
||||||
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
|
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
|
||||||
plan.noteOffFrame))
|
plan.noteOffFrame))
|
||||||
return std::nullopt;
|
return tooLong;
|
||||||
// Each field cleared the ceiling alone; the render holds their sum.
|
// Each field cleared the ceiling alone; the render holds their sum.
|
||||||
if (plan.renderFrames() > kMaxBakeFrames) return std::nullopt;
|
if (plan.renderFrames() > kMaxBakeFrames) return tooLong;
|
||||||
|
|
||||||
plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
|
plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
|
||||||
plan.note = std::clamp(rootNote, 0, 127);
|
plan.note = std::clamp(rootNote, 0, 127);
|
||||||
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
|
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
|
||||||
return plan;
|
return PlannedBake{plan, BakeRefusal::None};
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler::instrument::bake
|
} // namespace reasampler::instrument::bake
|
||||||
|
|||||||
@@ -20,14 +20,32 @@ namespace reasampler::instrument::bake {
|
|||||||
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
|
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
|
||||||
inline constexpr std::int64_t kMaxBakeFrames = 16'000'000;
|
inline constexpr std::int64_t kMaxBakeFrames = 16'000'000;
|
||||||
|
|
||||||
// The program a bake uses until the capture-signal popup ships: one quarter note at the
|
// Whether the window needs a user-supplied hold. A Gate voice over an ACTIVE sustain loop
|
||||||
// default velocity, opening at note-on, with the END offset derived from `dialed` — Gate's
|
// sounds for as long as it is held, by definition — there is no intrinsic end to derive, and
|
||||||
// release, or Trigger's play span, at `renderSampleRate` (the rate the bake will render at,
|
// this is the ONLY case in which there isn't. Answered by the engine's own loop fold, so the
|
||||||
// which is what the engine's frame counts are actually consumed against). Derived rather
|
// control that collects the hold cannot appear for a loop the voice would refuse.
|
||||||
// than constant because the release knob alone spans two seconds, so any fixed tail cuts a
|
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||||
// long decay mid-flight.
|
std::int64_t frameCount);
|
||||||
|
bool bakeWindowNeedsHold(const SampleData& dialed);
|
||||||
|
|
||||||
|
// The bake's programmed note, DERIVED from the dialed sound at `renderSampleRate` (the rate
|
||||||
|
// the bake renders at, which is what the engine's frame counts are consumed against):
|
||||||
|
//
|
||||||
|
// Trigger — note length is nominal (note-off is ignored); the end offset carries the
|
||||||
|
// whole play span, stretched by the deepest downward Varispeed offset.
|
||||||
|
// 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
|
||||||
|
// doing, so the note is rounded UP to the shortest programmable length that
|
||||||
|
// outlasts the source. Holding longer than that sounds identical, which is
|
||||||
|
// what makes the overshoot trailing silence rather than a different sound.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
// `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.
|
||||||
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
||||||
note::Tempo tempo);
|
note::Tempo tempo, note::Division hold,
|
||||||
|
note::Velocity velocity);
|
||||||
|
|
||||||
// The render window in frames. TWO domains meet here: `totalFrames` is the captured FILE's
|
// The render window in frames. TWO domains meet here: `totalFrames` is the captured FILE's
|
||||||
// length, everything else counts RENDER frames from whichever comes first, note-on or the
|
// length, everything else counts RENDER frames from whichever comes first, note-on or the
|
||||||
@@ -49,10 +67,23 @@ struct BakePlan {
|
|||||||
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
|
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// nullopt for a collapsed window, a non-positive rate, a window that rounds to no frames,
|
// Why a window was refused. The two are different user problems and read as different
|
||||||
// or one past kMaxBakeFrames — a buffer that is degenerate or unholdable is refused rather
|
// sentences: an empty window is a programming mistake, a window past the ceiling is a legal
|
||||||
// than rendered. `rootNote` and the resolved velocity are clamped into MIDI range.
|
// dialed sound that simply cannot be held in one pass.
|
||||||
std::optional<BakePlan> planBake(const note::ResolvedNote& resolved, int sampleRate,
|
enum class BakeRefusal : std::uint8_t {
|
||||||
int rootNote);
|
None,
|
||||||
|
EmptyWindow, // collapsed, a non-positive rate, or a window that rounds to no frames
|
||||||
|
PastFrameCeiling, // representable but longer than kMaxBakeFrames
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one `ResolvedNote` + rate -> frames resolution. A degenerate or unholdable window is
|
||||||
|
// refused rather than rendered; `refusal` is None iff `plan` holds one. `rootNote` and the
|
||||||
|
// resolved velocity are clamped into MIDI range.
|
||||||
|
struct PlannedBake {
|
||||||
|
std::optional<BakePlan> plan;
|
||||||
|
BakeRefusal refusal = BakeRefusal::None;
|
||||||
|
};
|
||||||
|
|
||||||
|
PlannedBake planBake(const note::ResolvedNote& resolved, int sampleRate, int rootNote);
|
||||||
|
|
||||||
} // namespace reasampler::instrument::bake
|
} // namespace reasampler::instrument::bake
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "core/instrument/map/trigger_seam.h" // effectiveLengthFraction (the one %-length rule)
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||||
@@ -101,13 +103,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
env_.noteOn();
|
env_.noteOn();
|
||||||
playEnd_ = 0; // unused in Gate
|
playEnd_ = 0; // unused in Gate
|
||||||
} else {
|
} else {
|
||||||
// Trigger: play [start, playEnd) where
|
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)).
|
||||||
// playEnd = start + round(lengthFraction*(frames-start)) — except kTrigLength is INERT
|
// The spline fold lives in effectiveLengthFraction (trigger_seam.h), which the bake's
|
||||||
// while any spline EG is active (splineActive, play_params.h): a contour is a pure
|
// window derivation reads too — a second copy of it here is what let a stored-but-inert
|
||||||
// function over the FULL sample length, so truncating playEnd_ to a %-length would
|
// %-knob shorten the bake while the voice played the whole take.
|
||||||
// hard-cut it mid-shape. The staged Trigger AHD below (trigSpan) plays the same full
|
double frac = instrument::map::effectiveLengthFraction(p);
|
||||||
// span in that case, matching the "drawn-but-dead" treatment of the other staged knobs.
|
|
||||||
double frac = splineActive(p) ? 1.0 : p.trigger.lengthFraction;
|
|
||||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||||
if (frac > 1.0) frac = 1.0;
|
if (frac > 1.0) frac = 1.0;
|
||||||
std::int64_t playLen = static_cast<std::int64_t>(
|
std::int64_t playLen = static_cast<std::int64_t>(
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ inline double filterNormPerOctave() {
|
|||||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||||
|
|
||||||
|
// How many frames the ramp emits before the weight drops under the floor. Counted the way
|
||||||
|
// advanceFrame runs it — emit, decay, test — rather than solved in closed form, so the two
|
||||||
|
// can never disagree. RATE-INDEPENDENT: the decay is per frame, not per second, so an offline
|
||||||
|
// pass at any rate pads by the same count.
|
||||||
|
inline constexpr std::int64_t declickRampFrames() {
|
||||||
|
std::int64_t n = 0;
|
||||||
|
for (double w = 1.0; w >= kDeclickFloor; w *= kDeclickDecay) ++n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
inline constexpr std::int64_t kDeclickFrames = declickRampFrames();
|
||||||
|
|
||||||
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
||||||
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
||||||
// pitch ratio; loops the sustain region for held notes past the loop end.
|
// pitch ratio; loops the sustain region for held notes past the loop end.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
|
|||||||
reasampler_test(bridge_marshal LINK bridge_marshal)
|
reasampler_test(bridge_marshal LINK bridge_marshal)
|
||||||
|
|
||||||
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
|
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
|
||||||
# Links only trigger_seam — not even editor_geometry — the plainest data-boundary proof
|
# The %-length fold lives beside the span formula, so the header reads play_params' value
|
||||||
# available.
|
# layer; the test links that set and nothing else — still no engine, no editor geometry.
|
||||||
reasampler_test(trigger_seam LINK trigger_seam)
|
reasampler_test(trigger_seam LINK trigger_seam velocity_curve peaks filter curve_law)
|
||||||
|
|
||||||
reasampler_pure_library(bank_sync
|
reasampler_pure_library(bank_sync
|
||||||
SOURCES bank_sync.cpp
|
SOURCES bank_sync.cpp
|
||||||
@@ -21,7 +21,7 @@ reasampler_test(bank_sync LINK bank_sync)
|
|||||||
# the format already keeps on independent version axes (see component_state_io.h).
|
# the format already keeps on independent version axes (see component_state_io.h).
|
||||||
reasampler_pure_library(component_state_io
|
reasampler_pure_library(component_state_io
|
||||||
SOURCES component_state_io.cpp params_payload.cpp
|
SOURCES component_state_io.cpp params_payload.cpp
|
||||||
LINK PUBLIC velocity_curve master_gain curve_law)
|
LINK PUBLIC velocity_curve master_gain curve_law musical_division)
|
||||||
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
||||||
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
||||||
reasampler_test(component_state_io LINK component_state_io)
|
reasampler_test(component_state_io LINK component_state_io)
|
||||||
@@ -39,7 +39,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
|
|||||||
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
|
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
|
||||||
reasampler_pure_library(sample_map
|
reasampler_pure_library(sample_map
|
||||||
SOURCES sample_map.cpp
|
SOURCES sample_map.cpp
|
||||||
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law)
|
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law
|
||||||
|
musical_division)
|
||||||
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
|
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
|
||||||
# both halves of the mapping/codec split where the frozen-format assertions live.
|
# both halves of the mapping/codec split where the frozen-format assertions live.
|
||||||
reasampler_test(sample_map LINK sample_map component_state_io)
|
reasampler_test(sample_map LINK sample_map component_state_io)
|
||||||
|
|||||||
@@ -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..v13) must be preserved exactly. This header is the ONE home for both ladders
|
// payload v1..v14) 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>
|
||||||
@@ -120,6 +120,15 @@ namespace reasampler::instrument::map {
|
|||||||
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
|
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
|
||||||
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
|
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
|
||||||
//
|
//
|
||||||
|
// v14 (CURRENT WRITE FORMAT) is v13 PLUS the resample bake's Hold division, appended after the
|
||||||
|
// hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0
|
||||||
|
// Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields —
|
||||||
|
// never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob
|
||||||
|
// is a strict prefix and lifts to one bar, and Hold reaches no audio path, so a pre-v14
|
||||||
|
// instance plays and bakes identically except where its window was underived to begin with.
|
||||||
|
// 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 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
|
||||||
@@ -151,7 +160,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 = 13; // v12 + the dual Staged/Spline state
|
inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division
|
||||||
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
|
||||||
@@ -179,6 +188,10 @@ inline constexpr std::uint32_t kParamsVelocityVersion = 12;
|
|||||||
// kParamsPayloadVersion.
|
// kParamsPayloadVersion.
|
||||||
inline constexpr std::uint32_t kParamsSplineVersion = 13;
|
inline constexpr std::uint32_t kParamsSplineVersion = 13;
|
||||||
|
|
||||||
|
// v13 + the bake Hold division; the appended tail branches on THIS, never on
|
||||||
|
// kParamsPayloadVersion.
|
||||||
|
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;
|
||||||
|
|
||||||
// (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
|
||||||
|
|||||||
@@ -193,6 +193,24 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) {
|
|||||||
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
|
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the v14 bake Hold. Same revive discipline as readHardFlags directly above, and for the
|
||||||
|
// same reason: this tail reaches no audio path, so a blob truncated inside it must cost the
|
||||||
|
// Hold alone and not reset the whole record that parsed cleanly ahead of it. It sits LAST, so
|
||||||
|
// a truncation stranding the hard flags strands this too — reviving in only one of the two
|
||||||
|
// would still wipe the record.
|
||||||
|
void readBakeHold(ByteReader& r, InstrumentParams& p) {
|
||||||
|
const bool enteredOk = r.ok;
|
||||||
|
const std::int32_t exponent = r.i32();
|
||||||
|
const std::uint8_t modifier = r.u8();
|
||||||
|
if (!r.ok) {
|
||||||
|
if (enteredOk) r.ok = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung
|
||||||
|
// rather than an unrepresentable one — never a memcpy into the type.
|
||||||
|
p.bakeHold = note::makeDivision(exponent, static_cast<note::DivisionModifier>(modifier));
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -418,6 +436,11 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
|
|||||||
putHardFlags(out, p.velocityCurve);
|
putHardFlags(out, p.velocityCurve);
|
||||||
putHardFlags(out, f.velocityCurve);
|
putHardFlags(out, f.velocityCurve);
|
||||||
putHardFlags(out, pp.pitchVelocityCurve);
|
putHardFlags(out, pp.pitchVelocityCurve);
|
||||||
|
// v14: the bake's Hold division, as its {quarterExponent, modifier} pair — never its
|
||||||
|
// picker index, which the ladder gaining a rung would silently re-map.
|
||||||
|
putLE(out, static_cast<std::uint32_t>(
|
||||||
|
static_cast<std::int32_t>(p.bakeHold.quarterExponent())));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(p.bakeHold.modifier()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -468,6 +491,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
|||||||
readHardFlags(r, p.play.filter.velocityCurve);
|
readHardFlags(r, p.play.filter.velocityCurve);
|
||||||
readHardFlags(r, p.play.pitchVelocityCurve);
|
readHardFlags(r, p.play.pitchVelocityCurve);
|
||||||
}
|
}
|
||||||
|
if (pv >= kParamsBakeHoldVersion) readBakeHold(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{};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||||
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
|
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
|
||||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the stored seconds value layer)
|
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the stored seconds value layer)
|
||||||
|
#include "core/instrument/note/musical_division.h" // Division (the bake hold's value domain)
|
||||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
@@ -188,6 +189,12 @@ struct InstrumentParams {
|
|||||||
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
|
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
|
||||||
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
||||||
PlaySeconds play;
|
PlaySeconds play;
|
||||||
|
|
||||||
|
// How long the resample bake holds the gate. Read ONLY when the bake window cannot be
|
||||||
|
// derived — a Gate voice over an active sustain loop, which sounds indefinitely
|
||||||
|
// (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating
|
||||||
|
// the field lifts to it, and no other bake changes.
|
||||||
|
note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
// trigger_seam — the shared Trigger play-span formula: how the stored %-length becomes the
|
// trigger_seam — the shared Trigger play-span formula: how the stored %-length becomes the
|
||||||
// source-frame span the voice plays and the overlay draws over. One home so the engine's
|
// source-frame span the voice plays, the overlay draws over, and the bake's window holds.
|
||||||
// note-on resolve and the editor's overlay pack cannot disagree about where a Trigger note
|
// One home so those three cannot disagree about where a Trigger note ends.
|
||||||
// ends.
|
|
||||||
//
|
//
|
||||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
// playLengthFrames = round(effectiveLengthFraction * (frameCount - startFrame))
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/play_params.h" // splineActive (the %-knob's inert rule)
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
|
// The %-length the voice ACTUALLY plays. A drawn contour is a pure time function over the
|
||||||
|
// full sample length, so any active spline EG folds the fraction to 1.0 and the stored knob
|
||||||
|
// goes inert (splineActive, play_params.h) — but the stored value survives, so a pre-spline
|
||||||
|
// setting is still there to be read. Every consumer of the span must fold it here or it
|
||||||
|
// silently plays/draws/bakes a fraction of the take.
|
||||||
|
//
|
||||||
|
// Templated over the two parameter representations for the same reason splineActive is.
|
||||||
|
template <class Play>
|
||||||
|
double effectiveLengthFraction(const Play& p) {
|
||||||
|
return splineActive(p) ? 1.0 : p.trigger.lengthFraction;
|
||||||
|
}
|
||||||
|
|
||||||
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
|
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
|
||||||
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
|
// `startFrame` is the effective start point (0 when absent), `lengthFraction` the EFFECTIVE
|
||||||
// or lengthFraction <= 0.
|
// one above. Returns 0 when postStart == 0 or lengthFraction <= 0.
|
||||||
std::int64_t triggerPlayLength(double lengthFraction,
|
std::int64_t triggerPlayLength(double lengthFraction,
|
||||||
std::int64_t frameCount,
|
std::int64_t frameCount,
|
||||||
std::int64_t startFrame);
|
std::int64_t startFrame);
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ types, no VST3 types, no host at all.
|
|||||||
It exists as its own directory because it is neither engine (it renders nothing), mapping
|
It exists as its own directory because it is neither engine (it renders nothing), mapping
|
||||||
(it resolves no capture and builds no `SampleData`), nor UI (it computes no geometry). It
|
(it resolves no capture and builds no `SampleData`), nor UI (it computes no geometry). It
|
||||||
is a performance *description* plus its arithmetic, read by two consumers that must not
|
is a performance *description* plus its arithmetic, read by two consumers that must not
|
||||||
diverge: the capture-signal popup that edits it and the bake that renders it.
|
diverge: the bake that renders it, and any surface that comes to edit it. Today the bake
|
||||||
|
DERIVES the whole record from the dialed sound rather than asking for it — the one field a
|
||||||
|
derivation cannot supply is the Gate-over-a-loop note length, and that arrives as a single
|
||||||
|
`Division` (see `bake/CLAUDE.md`), not as a hand-programmed record.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ int divisionIndex(Division d) {
|
|||||||
+ static_cast<int>(d.modifier());
|
+ static_cast<int>(d.modifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Division shortestDivisionAtLeast(double beats) {
|
||||||
|
// Picker order is NOT length order — a rung's triplet is shorter than the previous rung's
|
||||||
|
// dotted — so both the fit and the fallback are found by scanning. 39 entries.
|
||||||
|
Division longest = divisionAt(0);
|
||||||
|
double longestBeats = divisionBeats(longest);
|
||||||
|
Division best = longest;
|
||||||
|
double bestBeats = 0.0;
|
||||||
|
bool found = false;
|
||||||
|
for (int i = 0; i < kDivisionCount; ++i) {
|
||||||
|
const Division d = divisionAt(i);
|
||||||
|
const double b = divisionBeats(d);
|
||||||
|
if (b > longestBeats) { longest = d; longestBeats = b; }
|
||||||
|
if (b >= beats && (!found || b < bestBeats)) { best = d; bestBeats = b; found = true; }
|
||||||
|
}
|
||||||
|
if (!(beats > 0.0)) return divisionAt(0); // also catches NaN
|
||||||
|
return found ? best : longest;
|
||||||
|
}
|
||||||
|
|
||||||
std::string divisionLabel(Division d) {
|
std::string divisionLabel(Division d) {
|
||||||
const int e = d.quarterExponent();
|
const int e = d.quarterExponent();
|
||||||
// Both branches meet at e == 2 ("1/1"): a division's written form is its length in
|
// Both branches meet at e == 2 ("1/1"): a division's written form is its length in
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ double divisionBeats(Division d);
|
|||||||
Division divisionAt(int index);
|
Division divisionAt(int index);
|
||||||
int divisionIndex(Division d);
|
int divisionIndex(Division d);
|
||||||
|
|
||||||
|
// The shortest ladder length that is at least `beats` — the rung a caller reaches for when a
|
||||||
|
// derived duration has to be expressed as a programmable note and overshooting is the safe
|
||||||
|
// direction. Nothing long enough (or a non-finite `beats`) yields the top rung; a
|
||||||
|
// non-positive one yields the bottom.
|
||||||
|
Division shortestDivisionAtLeast(double beats);
|
||||||
|
|
||||||
// The notation divisions are named in: "1/16", "1/8.", "1/4t", "4/1".
|
// The notation divisions are named in: "1/16", "1/8.", "1/4t", "4/1".
|
||||||
std::string divisionLabel(Division d);
|
std::string divisionLabel(Division d);
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ reasampler_pure_library(deck_values
|
|||||||
LINK PUBLIC deck_groups play_seconds envelope_overlay)
|
LINK PUBLIC deck_groups play_seconds envelope_overlay)
|
||||||
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
|
||||||
|
# does not even take editor_geometry.
|
||||||
|
reasampler_pure_library(bake_hold SOURCES bake_hold.cpp LINK PUBLIC musical_division)
|
||||||
|
reasampler_test(bake_hold LINK bake_hold)
|
||||||
|
|
||||||
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
||||||
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
|
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
|
||||||
# proving that takes a curve of each domain mapped through the one curveBox.
|
# proving that takes a curve of each domain mapped through the one curveBox.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// bake_hold.cpp — see bake_hold.h. Pure math; no host types.
|
||||||
|
|
||||||
|
#include "core/instrument/ui/bake_hold.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kLastIndex = note::kDivisionCount - 1;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
note::Division bakeHoldFromNorm(double norm) {
|
||||||
|
if (!(norm > 0.0)) return note::divisionAt(0); // also catches NaN
|
||||||
|
if (norm >= 1.0) return note::divisionAt(kLastIndex);
|
||||||
|
// Round to nearest so each rung owns an equal slice of the knob's travel; divisionAt
|
||||||
|
// clamps, so the +0.5 landing on kDivisionCount at norm just under 1 is harmless.
|
||||||
|
return note::divisionAt(static_cast<int>(norm * kLastIndex + 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
double bakeHoldNorm(note::Division hold) {
|
||||||
|
return static_cast<double>(note::divisionIndex(hold)) / static_cast<double>(kLastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
// bake_hold — how one radial knob addresses the note-length ladder: the Hold control's
|
||||||
|
// normalized [0,1] position mapped onto a Division and back. The ladder and its picker order
|
||||||
|
// are musical_division's; nothing about them is restated here.
|
||||||
|
|
||||||
|
#include "core/instrument/note/musical_division.h"
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
// [0,1] across the ladder in picker order (shortest rung first). Out-of-range or non-finite
|
||||||
|
// input clamps to an end rather than wrapping — a knob cannot express anything else.
|
||||||
|
note::Division bakeHoldFromNorm(double norm);
|
||||||
|
|
||||||
|
// The inverse: the position that reproduces `hold` exactly, so a knob painted from a stored
|
||||||
|
// value and then released without moving cannot shift it a rung.
|
||||||
|
double bakeHoldNorm(note::Division hold);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -20,6 +20,7 @@ constexpr int kRunGap = 6; // between adjacent items of the toolbar run
|
|||||||
constexpr int kChanSegW = 52;
|
constexpr int kChanSegW = 52;
|
||||||
constexpr int kChanSegH = 18;
|
constexpr int kChanSegH = 18;
|
||||||
constexpr int kVelCellW = 56;
|
constexpr int kVelCellW = 56;
|
||||||
|
constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell
|
||||||
constexpr int kVelLabelH = 16;
|
constexpr int kVelLabelH = 16;
|
||||||
constexpr int kPreviewBtnW = 64;
|
constexpr int kPreviewBtnW = 64;
|
||||||
constexpr int kRunButtonH = 24; // Browse and Preview
|
constexpr int kRunButtonH = 24; // Browse and Preview
|
||||||
@@ -42,8 +43,8 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
|||||||
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
||||||
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
||||||
|
|
||||||
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake. The
|
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake, hold.
|
||||||
// velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
|
// The velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
|
||||||
const int navH = std::min(kRunButtonH, row.height);
|
const int navH = std::min(kRunButtonH, row.height);
|
||||||
const int navTop = topFor(navH);
|
const int navTop = topFor(navH);
|
||||||
const int navRight = std::max(row.x, row.right() - kPad);
|
const int navRight = std::max(row.x, row.right() - kPad);
|
||||||
@@ -76,9 +77,19 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
|||||||
r.bake = Rect::ltrb(leftOf(bakeRight, kBakeButtonWidth), prevTop, bakeRight,
|
r.bake = Rect::ltrb(leftOf(bakeRight, kBakeButtonWidth), prevTop, bakeRight,
|
||||||
prevTop + std::min(kRunButtonH, row.height));
|
prevTop + std::min(kRunButtonH, row.height));
|
||||||
|
|
||||||
|
// Hold: the same knob-cell grammar as the velocity cell, immediately left of Bake.
|
||||||
|
const int holdRight = leftOf(r.bake.x, kRunGap);
|
||||||
|
r.holdCell = Rect::ltrb(leftOf(holdRight, kHoldCellW), cellTop, holdRight,
|
||||||
|
cellTop + cellH);
|
||||||
|
const int holdKnobLeft = r.holdCell.x + (r.holdCell.width - knobSize) / 2;
|
||||||
|
r.holdKnob = Rect::ltrb(holdKnobLeft, r.holdCell.y, holdKnobLeft + knobSize,
|
||||||
|
r.holdCell.y + std::min(knobSize, cellH));
|
||||||
|
r.holdLabel = Rect::ltrb(r.holdCell.x, r.holdKnob.bottom(), r.holdCell.right(),
|
||||||
|
r.holdCell.bottom());
|
||||||
|
|
||||||
// The title takes what the run leaves; clamped so a narrow window collapses it rather
|
// The title takes what the run leaves; clamped so a narrow window collapses it rather
|
||||||
// than inverting it.
|
// than inverting it.
|
||||||
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.bake.x - kRunGap),
|
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.holdCell.x - kRunGap),
|
||||||
row.bottom());
|
row.bottom());
|
||||||
|
|
||||||
if (r.controls.empty()) return r;
|
if (r.controls.empty()) return r;
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ inline constexpr int kBakeButtonWidth = 54; // the resample-bake trigger
|
|||||||
struct ChromeRects {
|
struct ChromeRects {
|
||||||
Rect toolbar; // full-width top row
|
Rect toolbar; // full-width top row
|
||||||
Rect title; // the title text slot: the toolbar left of the control run
|
Rect title; // the title text slot: the toolbar left of the control run
|
||||||
Rect bake; // ---- the right-anchored run, left to right ----
|
// The bake Hold cell. Its width is RESERVED unconditionally even though the control is
|
||||||
|
// only load-bearing for a looped Gate sound (bake_plan.h's bakeWindowNeedsHold): the run
|
||||||
|
// is right-anchored, so laying it out conditionally would slide Bake and Preview out from
|
||||||
|
// under the pointer whenever a loop is dialled in or out.
|
||||||
|
Rect holdCell; // ---- the right-anchored run, left to right ----
|
||||||
|
Rect holdKnob;
|
||||||
|
Rect holdLabel;
|
||||||
|
Rect bake;
|
||||||
Rect preview;
|
Rect preview;
|
||||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||||
Rect velKnob;
|
Rect velKnob;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
|||||||
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||||
`deck_groups`, `deck_values`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
@@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
|||||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||||
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||||
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules, stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
|
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
|
||||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||||
@@ -122,7 +122,10 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
|||||||
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
|
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
|
||||||
one.
|
one.
|
||||||
- The bake's availability probe runs on the SAME tick that paints the button, so the
|
- The bake's availability probe runs on the SAME tick that paints the button, so the
|
||||||
control can never be enabled on one tick and refuse on the next.
|
control can never be enabled on one tick and refuse on the next. The bake Hold control's
|
||||||
|
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
|
||||||
|
because answering it costs a bridge read + bank parse whenever no loop override is set —
|
||||||
|
do not move either into the paint path.
|
||||||
- `editor_internal.h` is include-only — it has no TU of its own and must never become
|
- `editor_internal.h` is include-only — it has no TU of its own and must never become
|
||||||
a public seam; only the `reasampler_editor` band-axis TUs include it.
|
a public seam; only the `reasampler_editor` band-axis TUs include it.
|
||||||
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
|
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
waveform_view bank_sync browser_scroll param_slider tooltip
|
waveform_view bank_sync browser_scroll param_slider tooltip
|
||||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||||
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
||||||
|
bake_hold
|
||||||
file_bytes curve_law stroke_aa
|
file_bytes curve_law stroke_aa
|
||||||
bake_plan bake_render bake_reset bake_wire wav_codec)
|
bake_plan bake_render bake_reset bake_wire wav_codec)
|
||||||
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
|
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
|
||||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||||
|
#include "core/instrument/ui/bake_hold.h" // the Hold knob's ladder map
|
||||||
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
||||||
#include "core/instrument/ui/deck_values.h" // the parameter-set binding + its ms units
|
#include "core/instrument/ui/deck_values.h" // the parameter-set binding + its ms units
|
||||||
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
||||||
@@ -112,8 +113,13 @@ double ReaSamplerEditor::previewVelocity01() const {
|
|||||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double ReaSamplerEditor::bakeHoldNorm() const {
|
||||||
|
return instrument::ui::bakeHoldNorm(params_.bakeHold);
|
||||||
|
}
|
||||||
|
|
||||||
double ReaSamplerEditor::deckControlNorm(int id) const {
|
double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||||
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
||||||
|
if (id == kBakeHoldKnobId) return bakeHoldNorm();
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kKeyTrack:
|
case ParamControl::kKeyTrack:
|
||||||
return clamp01(params_.keyTrack / kKeyTrackMax);
|
return clamp01(params_.keyTrack / kKeyTrackMax);
|
||||||
@@ -145,6 +151,11 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
|||||||
processor_->setPreviewVelocity(static_cast<std::uint8_t>(norm * 127.0 + 0.5));
|
processor_->setPreviewVelocity(static_cast<std::uint8_t>(norm * 127.0 + 0.5));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (id == kBakeHoldKnobId) {
|
||||||
|
// A parameter-set edit like the deck's own knobs: the commit lands on release.
|
||||||
|
params_.bakeHold = instrument::ui::bakeHoldFromNorm(norm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kVoiceCount: {
|
case ParamControl::kVoiceCount: {
|
||||||
// Stepped: quantize the continuous drag to the integer count and track it live
|
// Stepped: quantize the continuous drag to the integer count and track it live
|
||||||
@@ -171,7 +182,7 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
char buf[24];
|
char buf[24];
|
||||||
buf[0] = '\0';
|
buf[0] = '\0';
|
||||||
const PlaySeconds& play = params_.play;
|
const PlaySeconds& play = params_.play;
|
||||||
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
switch (id < 0 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kAttack:
|
case ParamControl::kAttack:
|
||||||
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
|
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
|
||||||
case ParamControl::kHold:
|
case ParamControl::kHold:
|
||||||
@@ -263,7 +274,8 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play));
|
snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
// The chrome knobs (preview velocity, bake Hold) are labeled at their own call
|
||||||
|
// site; nothing else here.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return std::string(buf);
|
return std::string(buf);
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Bake Hold: the same grab-anchored knob drag, and inert on exactly the ticks it is not
|
||||||
|
// drawn — the cell's width is reserved either way (sample_chrome.h says why).
|
||||||
|
if (bakeHoldNeeded_ && contains(cr.holdCell, x, y)) {
|
||||||
|
drag_ = DragKind::kDeckKnob;
|
||||||
|
dragParamId_ = kBakeHoldKnobId;
|
||||||
|
dragInnerCellId_ = -1;
|
||||||
|
dragKnobStartValue_ = bakeHoldNorm();
|
||||||
|
dragStartParams_ = params_;
|
||||||
|
dragStartX_ = x;
|
||||||
|
dragStartY_ = y;
|
||||||
|
invalidate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
||||||
// jumps the value; the delta from the grab point maps via knobDragValue.
|
// jumps the value; the delta from the grab point maps via knobDragValue.
|
||||||
if (contains(cr.velCell, x, y)) {
|
if (contains(cr.velCell, x, y)) {
|
||||||
@@ -94,6 +107,14 @@ bool ReaSamplerEditor::doubleClickChrome(const FaceLayout& fl, int x, int y) {
|
|||||||
// radial knob drawn by the same primitive, so it resolves through the same target rule
|
// radial knob drawn by the same primitive, so it resolves through the same target rule
|
||||||
// (inKnobFace), against the drawn circle rather than the cell's label band.
|
// (inKnobFace), against the drawn circle rather than the cell's label band.
|
||||||
if (selectedId_.empty() || !processor_) return false;
|
if (selectedId_.empty() || !processor_) return false;
|
||||||
|
if (bakeHoldNeeded_ && inKnobFace(fl.chrome.holdKnob, x, y)) {
|
||||||
|
// The default is READ off a default-constructed parameter set, so there is no second
|
||||||
|
// table of defaults to drift from the codec's own lift.
|
||||||
|
params_.bakeHold = InstrumentParams{}.bakeHold;
|
||||||
|
commitAndReload();
|
||||||
|
invalidate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!inKnobFace(fl.chrome.velKnob, x, y)) return false;
|
if (!inKnobFace(fl.chrome.velKnob, x, y)) return false;
|
||||||
processor_->setPreviewVelocity(kPreviewVelocityDefault);
|
processor_->setPreviewVelocity(kPreviewVelocityDefault);
|
||||||
invalidate();
|
invalidate();
|
||||||
@@ -117,6 +138,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
|
|||||||
const ChromeRects& cr = fl.chrome;
|
const ChromeRects& cr = fl.chrome;
|
||||||
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
|
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
|
||||||
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
|
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
|
||||||
|
if (bakeHoldNeeded_ && contains(cr.holdCell, x, y)) return {HoverKind::kHoldKnob, -1};
|
||||||
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
|
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
|
||||||
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
||||||
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
||||||
|
|||||||
@@ -67,16 +67,15 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
|
|||||||
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
|
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
|
||||||
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
|
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
|
||||||
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
|
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
|
||||||
|
// effectiveLengthFraction, not the stored knob: the overlay's amp/filter AHD must read the
|
||||||
|
// SAME span the engine plays, or its drawn shape and node drags cover only a fraction of
|
||||||
|
// what the note actually does.
|
||||||
const std::int64_t playLen =
|
const std::int64_t playLen =
|
||||||
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
|
triggerPlayLength(effectiveLengthFraction(play), frames, startFrame);
|
||||||
const double fullSpan =
|
const double fullSpan =
|
||||||
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
|
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
|
||||||
: 0.0;
|
: 0.0;
|
||||||
// Voice::start makes kTrigLength inert while any spline EG is active (trigSpan == postStart,
|
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
|
||||||
// not the %-length) — the overlay's amp/filter AHD must read the SAME span the engine plays,
|
|
||||||
// or its drawn shape and node drags cover only a fraction of what the note actually does.
|
|
||||||
const double playSpan =
|
|
||||||
splineActive(play) ? fullSpan : (rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0);
|
|
||||||
const bool trigger = (play.playMode == PlayMode::Trigger);
|
const bool trigger = (play.playMode == PlayMode::Trigger);
|
||||||
switch (which) {
|
switch (which) {
|
||||||
case OverlayEnv::kPitch:
|
case OverlayEnv::kPitch:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/instrument/note/musical_division.h" // divisionLabel (the Hold readout)
|
||||||
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / noteName
|
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / noteName
|
||||||
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
|
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
|
||||||
#include "core/ui/tooltip.h" // computeTooltip (shared placement math)
|
#include "core/ui/tooltip.h" // computeTooltip (shared placement math)
|
||||||
@@ -145,6 +146,22 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
|||||||
drawButton(bmp, box, "Bake", st, /*warn=*/false);
|
drawButton(bmp, box, "Bake", st, /*warn=*/false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bake Hold: the note length the bake holds the gate for. Drawn ONLY while the window
|
||||||
|
// cannot be derived without it — every other dialed sound bakes with no user input, and a
|
||||||
|
// control that did nothing there would read as one that did.
|
||||||
|
if (bakeHoldNeeded_) {
|
||||||
|
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == kBakeHoldKnobId);
|
||||||
|
const bool hov = isHovered(HoverKind::kHoldKnob, -1);
|
||||||
|
const InteractionState st = dragging ? InteractionState::Dragging
|
||||||
|
: (hov ? InteractionState::Hover
|
||||||
|
: InteractionState::Rest);
|
||||||
|
drawKnobFace(bmp, cr.holdKnob, bakeHoldNorm(), st);
|
||||||
|
const std::string label = dragging || hov
|
||||||
|
? instrument::note::divisionLabel(params_.bakeHold)
|
||||||
|
: std::string("Hold");
|
||||||
|
kitTextCentered(bmp, cr.holdLabel, label.c_str(), Font::Micro, Role::TextDim);
|
||||||
|
}
|
||||||
|
|
||||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||||
// A drawn play triangle rather than a label or an embedded image: it inherits the button's
|
// A drawn play triangle rather than a label or an embedded image: it inherits the button's
|
||||||
// own foreground role, so it stays legible in every interaction state at no build cost.
|
// own foreground role, so it stays legible in every interaction state at no build cost.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
|
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
|
||||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||||
#include "ext_keys.h"
|
#include "ext_keys.h"
|
||||||
|
#include "core/instrument/bake/bake_plan.h" // bakeWindowNeedsHold (the Hold predicate)
|
||||||
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
|
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
|
||||||
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
|
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
|
||||||
#include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs
|
#include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs
|
||||||
@@ -116,6 +117,13 @@ void ReaSamplerEditor::onSyncTimer() {
|
|||||||
bakeAvailable_ = available;
|
bakeAvailable_ = available;
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
// Same cadence for the Hold control's applicability, so it too can never paint live on
|
||||||
|
// one tick and be inert on the next.
|
||||||
|
const bool holdNeeded = resolveBakeHoldNeeded();
|
||||||
|
if (holdNeeded != bakeHoldNeeded_) {
|
||||||
|
bakeHoldNeeded_ = holdNeeded;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
// The click armed it; this is where it runs — same thread, one tick later, no mouse
|
// The click armed it; this is where it runs — same thread, one tick later, no mouse
|
||||||
// capture held, and no REAPER action nested inside a mouse handler.
|
// capture held, and no REAPER action nested inside a mouse handler.
|
||||||
@@ -254,6 +262,18 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ReaSamplerEditor::resolveBakeHoldNeeded() {
|
||||||
|
// The mode test comes first so the common Trigger case never pays pickedMarkers' bridge
|
||||||
|
// read. `m.hasLoop` alone is not the answer — the engine's fold also refuses a span that
|
||||||
|
// reaches outside the PCM, which is exactly what the pure predicate is asked for.
|
||||||
|
if (selectedId_.empty() || params_.play.playMode != PlayMode::Gate) return false;
|
||||||
|
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
|
||||||
|
if (frames <= 0) return false;
|
||||||
|
const SetupMarkers m = pickedMarkers(frames);
|
||||||
|
return instrument::bake::bakeWindowNeedsHold(
|
||||||
|
PlayMode::Gate, SampleLoop{m.hasLoop, m.loopStart, m.loopEnd}, m.crossfade, frames);
|
||||||
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
|
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
|
||||||
// Write the edited markers into the parameter set as the loop/start override. The bank
|
// Write the edited markers into the parameter set as the loop/start override. The bank
|
||||||
// intrinsic is never written (read-only bank consumer).
|
// intrinsic is never written (read-only bank consumer).
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ using instrument::bake::resetAfterBake;
|
|||||||
using instrument::map::SampleRefEntry;
|
using instrument::map::SampleRefEntry;
|
||||||
using instrument::map::SelectedSample;
|
using instrument::map::SelectedSample;
|
||||||
using instrument::note::Tempo;
|
using instrument::note::Tempo;
|
||||||
|
using instrument::note::Velocity;
|
||||||
using instrument::note::resolveNote;
|
using instrument::note::resolveNote;
|
||||||
using wire::BakeOutcome;
|
using wire::BakeOutcome;
|
||||||
using wire::BakeRequest;
|
using wire::BakeRequest;
|
||||||
@@ -127,13 +128,23 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
|||||||
std::optional<SampleData> snapshot = processor.bakeSnapshot();
|
std::optional<SampleData> snapshot = processor.bakeSnapshot();
|
||||||
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
|
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
|
||||||
|
|
||||||
const auto plan = planBake(
|
// The note fires at the velocity the user has been auditioning at: three velocity curves
|
||||||
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo), *tempo), sampleRate,
|
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
|
||||||
rootNote);
|
const Velocity velocity = Velocity::of(processor.previewVelocity());
|
||||||
if (!plan) return fail("the programmed capture window is empty");
|
const instrument::bake::PlannedBake planned = planBake(
|
||||||
|
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo, dialed.bakeHold,
|
||||||
|
velocity),
|
||||||
|
*tempo),
|
||||||
|
sampleRate, rootNote);
|
||||||
|
if (!planned.plan) {
|
||||||
|
return fail(planned.refusal == instrument::bake::BakeRefusal::PastFrameCeiling
|
||||||
|
? "the dialed sound is longer than one bake can hold"
|
||||||
|
: "the programmed capture window is empty");
|
||||||
|
}
|
||||||
|
const instrument::bake::BakePlan& plan = *planned.plan;
|
||||||
|
|
||||||
const instrument::bake::BakeAudio audio =
|
const instrument::bake::BakeAudio audio =
|
||||||
renderBake(std::move(*snapshot), *plan, processor.masterGainLinear());
|
renderBake(std::move(*snapshot), plan, processor.masterGainLinear());
|
||||||
if (audio.empty()) return fail("the offline pass produced no audio");
|
if (audio.empty()) return fail("the offline pass produced no audio");
|
||||||
|
|
||||||
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
|
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
|
||||||
@@ -164,7 +175,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
|||||||
request.sourceRelativePath = source->relativePath;
|
request.sourceRelativePath = source->relativePath;
|
||||||
request.sourceDisplayName = sourceEntry->displayName;
|
request.sourceDisplayName = sourceEntry->displayName;
|
||||||
request.ownUsageKey = usageKeyFor(instanceGuid);
|
request.ownUsageKey = usageKeyFor(instanceGuid);
|
||||||
request.rootNote = plan->note;
|
request.rootNote = plan.note;
|
||||||
request.generation = stamp;
|
request.generation = stamp;
|
||||||
|
|
||||||
const std::string key = bakeKeyFor(instanceGuid);
|
const std::string key = bakeKeyFor(instanceGuid);
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ private:
|
|||||||
// the pure deck_groups module's — this alias keeps the shell's spelling.
|
// the pure deck_groups module's — this alias keeps the shell's spelling.
|
||||||
using ParamControl = instrument::ui::DeckParam;
|
using ParamControl = instrument::ui::DeckParam;
|
||||||
|
|
||||||
|
// The chrome knobs are drags of kind kDeckKnob with no place in the deck's id space, so
|
||||||
|
// they take negative sentinels (-2 is the preview velocity). Being outside
|
||||||
|
// [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control".
|
||||||
|
static constexpr int kBakeHoldKnobId = -3;
|
||||||
|
|
||||||
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
|
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
|
||||||
// in draw + hit order, then the crossfade handle. The crossfade is NOT part of the
|
// in draw + hit order, then the crossfade handle. The crossfade is NOT part of the
|
||||||
// full-height column hit-test — it answers only in its top-strip handle (waveform_view's
|
// full-height column hit-test — it answers only in its top-strip handle (waveform_view's
|
||||||
@@ -127,6 +132,7 @@ private:
|
|||||||
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
|
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
|
||||||
kCurveNode, // a velocity-curve control point (index = point index)
|
kCurveNode, // a velocity-curve control point (index = point index)
|
||||||
kVelKnob, // the chrome preview-velocity radial knob
|
kVelKnob, // the chrome preview-velocity radial knob
|
||||||
|
kHoldKnob, // the chrome bake-Hold radial knob
|
||||||
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
|
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
|
||||||
kPopupClose, // the curve popup's Close (x) button
|
kPopupClose, // the curve popup's Close (x) button
|
||||||
};
|
};
|
||||||
@@ -334,6 +340,11 @@ private:
|
|||||||
};
|
};
|
||||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||||
|
|
||||||
|
// Whether the loaded sound's bake window needs the user's Hold — the pure predicate
|
||||||
|
// (bake_plan.h) answered against the markers this face is showing. Decodes and reads the
|
||||||
|
// bank, so it is called on the sync tick, not per paint.
|
||||||
|
bool resolveBakeHoldNeeded();
|
||||||
|
|
||||||
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
|
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
|
||||||
// callers decide live-drag vs final commit.
|
// callers decide live-drag vs final commit.
|
||||||
void applyMarkers(const SetupMarkers& m);
|
void applyMarkers(const SetupMarkers& m);
|
||||||
@@ -378,6 +389,8 @@ private:
|
|||||||
double liveSampleRate() const;
|
double liveSampleRate() const;
|
||||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||||
double previewVelocity01() const;
|
double previewVelocity01() const;
|
||||||
|
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
|
||||||
|
double bakeHoldNorm() const;
|
||||||
|
|
||||||
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
|
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
|
||||||
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
||||||
@@ -430,6 +443,10 @@ private:
|
|||||||
// Whether the extension's bake action is registered, resolved on the same tick that
|
// Whether the extension's bake action is registered, resolved on the same tick that
|
||||||
// governs the button's paint, so the control is never enabled and then refusing.
|
// governs the button's paint, so the control is never enabled and then refusing.
|
||||||
bool bakeAvailable_ = false;
|
bool bakeAvailable_ = false;
|
||||||
|
// Whether the bake's window still needs the Hold control (bake_plan.h's
|
||||||
|
// bakeWindowNeedsHold). Resolved on the same tick as bakeAvailable_ rather than per paint:
|
||||||
|
// answering it costs a bridge read + bank parse whenever no loop override is set.
|
||||||
|
bool bakeHoldNeeded_ = false;
|
||||||
std::string bakeMessage_; // last outcome, shown in the title band
|
std::string bakeMessage_; // last outcome, shown in the title band
|
||||||
int bakeMessageTicks_ = 0; // sync ticks the message survives
|
int bakeMessageTicks_ = 0; // sync ticks the message survives
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Standalone tests for reasampler::instrument::ui::bake_hold — the Hold knob's map onto the
|
||||||
|
// note-length ladder. No VST3, no REAPER, no framework.
|
||||||
|
//
|
||||||
|
// Covers: both ends of the knob, the round trip from every rung, out-of-range and non-finite
|
||||||
|
// input, and that every rung is reachable (no rung is skipped by the rounding).
|
||||||
|
|
||||||
|
#include "../src/core/instrument/ui/bake_hold.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler::instrument::ui;
|
||||||
|
using namespace reasampler::instrument::note;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// --- The ends of the travel are the ends of the ladder ---------------------------
|
||||||
|
CHECK(bakeHoldFromNorm(0.0) == divisionAt(0));
|
||||||
|
CHECK(bakeHoldFromNorm(1.0) == divisionAt(kDivisionCount - 1));
|
||||||
|
|
||||||
|
// --- Out of range clamps rather than wrapping ------------------------------------
|
||||||
|
CHECK(bakeHoldFromNorm(-3.0) == divisionAt(0));
|
||||||
|
CHECK(bakeHoldFromNorm(9.5) == divisionAt(kDivisionCount - 1));
|
||||||
|
CHECK(bakeHoldFromNorm(std::nan("")) == divisionAt(0));
|
||||||
|
|
||||||
|
// --- Round trip: a knob painted from a stored rung and released reproduces it -----
|
||||||
|
for (int i = 0; i < kDivisionCount; ++i) {
|
||||||
|
const Division d = divisionAt(i);
|
||||||
|
CHECK(bakeHoldFromNorm(bakeHoldNorm(d)) == d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Every rung is reachable from the knob, and each owns a contiguous slice ------
|
||||||
|
// Swept finely enough to catch a rounding that skipped one: 39 rungs over [0,1].
|
||||||
|
{
|
||||||
|
std::vector<bool> seen(static_cast<std::size_t>(kDivisionCount), false);
|
||||||
|
for (int step = 0; step <= 4000; ++step) {
|
||||||
|
const Division d = bakeHoldFromNorm(static_cast<double>(step) / 4000.0);
|
||||||
|
seen[static_cast<std::size_t>(divisionIndex(d))] = true;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < kDivisionCount; ++i) CHECK(seen[static_cast<std::size_t>(i)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The map is monotone: turning the knob up never shortens the note -------------
|
||||||
|
{
|
||||||
|
int previous = -1;
|
||||||
|
for (int step = 0; step <= 4000; ++step) {
|
||||||
|
const int index = divisionIndex(bakeHoldFromNorm(static_cast<double>(step) / 4000.0));
|
||||||
|
CHECK(index >= previous);
|
||||||
|
previous = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("bake_hold: all tests passed\n");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
+111
-46
@@ -1,13 +1,15 @@
|
|||||||
// Standalone tests for reasampler::instrument::bake::bake_plan — no VST3, no REAPER, no
|
// Standalone tests for reasampler::instrument::bake::bake_plan — no VST3, no REAPER, no
|
||||||
// framework. Same fast assert loop as the sibling pure tests.
|
// framework. Same fast assert loop as the sibling pure tests.
|
||||||
//
|
//
|
||||||
// Covers: the default program's window derived from the dialed sound (a Gate release, a
|
// Covers: the default program's window derived from the dialed sound (Gate's hold to source
|
||||||
// Trigger play span, and the Varispeed read-stretch bound); the frame window and both event
|
// exhaustion plus its release, a Trigger play span, and the Varispeed read-stretch bound);
|
||||||
// frames against hand-computed values; a capture opening BEFORE note-on and one opening
|
// the frame window and both event frames against hand-computed values; a capture opening
|
||||||
// AFTER it; the refusals — a collapsed window, a non-positive rate, a window that rounds to
|
// BEFORE note-on and one opening AFTER it; the refusals and what each one reports — a
|
||||||
// nothing, and one past the frame ceiling; and root/velocity clamping.
|
// collapsed window, a non-positive rate, a window that rounds to nothing, and one past the
|
||||||
|
// frame ceiling; and root/velocity clamping.
|
||||||
|
|
||||||
#include "../src/core/instrument/bake/bake_plan.h"
|
#include "../src/core/instrument/bake/bake_plan.h"
|
||||||
|
#include "../src/core/instrument/engine/voice.h" // kDeclickFrames (the window's tail pad)
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
@@ -29,6 +31,9 @@ namespace {
|
|||||||
|
|
||||||
constexpr int kRate = 48000;
|
constexpr int kRate = 48000;
|
||||||
|
|
||||||
|
// Every derived window carries the voice's terminal declick ramp as trailing silence.
|
||||||
|
const double kPadSeconds = static_cast<double>(kDeclickFrames) / kRate;
|
||||||
|
|
||||||
SampleData dialedSample(std::size_t frames = 96000) {
|
SampleData dialedSample(std::size_t frames = 96000) {
|
||||||
SampleData s;
|
SampleData s;
|
||||||
s.frames.assign(frames, 0.5f);
|
s.frames.assign(frames, 0.5f);
|
||||||
@@ -37,27 +42,44 @@ SampleData dialedSample(std::size_t frames = 96000) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Hold default; read only where the window is underivable, which is nowhere in this file.
|
||||||
|
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
|
||||||
|
|
||||||
|
NoteProgram derived(const SampleData& s, Tempo t) {
|
||||||
|
return defaultBakeProgram(s, kRate, t, oneBar(), Velocity{});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool near(double a, double b) { return a > b - 1e-6 && a < b + 1e-6; }
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
// --- The default program's window comes from the DIALED release, not a constant -----
|
// --- Gate with no loop: held to source exhaustion, then the DIALED release ----------
|
||||||
{
|
{
|
||||||
SampleData s = dialedSample();
|
SampleData s = dialedSample(); // 2 s of source == 4 beats
|
||||||
s.play.playMode = PlayMode::Gate;
|
s.play.playMode = PlayMode::Gate;
|
||||||
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
|
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
|
||||||
const NoteProgram p = defaultBakeProgram(s, kRate, at(120.0));
|
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
// The note is rounded up to the shortest length outlasting the source — 2 s exactly,
|
||||||
// A quarter note at 120 BPM is 0.5 s; the window must hold the whole 1.5 s release.
|
// one bar at 120 BPM — instead of the constant quarter note that released it early.
|
||||||
CHECK(r.noteOffSeconds > 0.499 && r.noteOffSeconds < 0.501);
|
CHECK(near(r.noteOffSeconds, 2.0));
|
||||||
CHECK(r.captureStartSeconds == 0.0);
|
CHECK(r.captureStartSeconds == 0.0);
|
||||||
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
CHECK(near(r.captureEndSeconds, 2.0 + 1.5 + kPadSeconds));
|
||||||
CHECK(!r.windowCollapsed);
|
CHECK(!r.windowCollapsed);
|
||||||
|
|
||||||
// A shorter release yields a shorter window — the derivation really reads the knob.
|
// A shorter release yields a shorter window — the derivation really reads the knob.
|
||||||
s.play.adsr.releaseFrames = kRate / 10; // 0.1 s
|
s.play.adsr.releaseFrames = kRate / 10; // 0.1 s
|
||||||
const ResolvedNote shorter = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
const ResolvedNote shorter = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||||
at(120.0));
|
CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds));
|
||||||
CHECK(shorter.captureEndSeconds > 0.599 && shorter.captureEndSeconds < 0.601);
|
|
||||||
|
// A source that does not land on a rung rounds UP. 2.5 s is 5 beats, and the shortest
|
||||||
|
// rung at or above that is the 2/1 triplet (8 * 2/3 == 5.33 beats) — NOT the dotted
|
||||||
|
// half above it, which is why picker order is not length order.
|
||||||
|
SampleData odd = dialedSample(static_cast<std::size_t>(kRate * 5 / 2)); // 2.5 s
|
||||||
|
odd.play.playMode = PlayMode::Gate;
|
||||||
|
const ResolvedNote up = resolveNote(derived(odd, at(120.0)), at(120.0));
|
||||||
|
CHECK(up.noteOffSeconds >= 2.5); // the property that matters: never short
|
||||||
|
CHECK(near(up.noteOffSeconds, at(120.0).beatsToSeconds(8.0 * 2.0 / 3.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Trigger: the window is the play span, which ignores the note's length ----------
|
// --- Trigger: the window is the play span, which ignores the note's length ----------
|
||||||
@@ -65,18 +87,16 @@ int main() {
|
|||||||
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s of source
|
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s of source
|
||||||
s.play.playMode = PlayMode::Trigger;
|
s.play.playMode = PlayMode::Trigger;
|
||||||
s.play.trigger.lengthFraction = 0.75; // 1.5 s of it
|
s.play.trigger.lengthFraction = 0.75; // 1.5 s of it
|
||||||
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||||
at(120.0));
|
|
||||||
CHECK(r.captureStartSeconds == 0.0);
|
CHECK(r.captureStartSeconds == 0.0);
|
||||||
CHECK(r.captureEndSeconds > 1.49 && r.captureEndSeconds < 1.51);
|
CHECK(near(r.captureEndSeconds, 1.5 + kPadSeconds));
|
||||||
|
|
||||||
// A span SHORTER than the quarter note closes the window early rather than padding
|
// A span SHORTER than the quarter note closes the window early rather than padding
|
||||||
// it out to note-off — the sound is over, and a negative end offset is legal.
|
// it out to note-off — the sound is over, and a negative end offset is legal.
|
||||||
s.play.trigger.lengthFraction = 0.1; // 0.2 s
|
s.play.trigger.lengthFraction = 0.1; // 0.2 s
|
||||||
const ResolvedNote brief = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||||
at(120.0));
|
|
||||||
CHECK(!brief.windowCollapsed);
|
CHECK(!brief.windowCollapsed);
|
||||||
CHECK(brief.captureEndSeconds > 0.199 && brief.captureEndSeconds < 0.201);
|
CHECK(near(brief.captureEndSeconds, 0.2 + kPadSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
|
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
|
||||||
@@ -86,16 +106,56 @@ int main() {
|
|||||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
s.play.pitchEnv.enabled = true;
|
s.play.pitchEnv.enabled = true;
|
||||||
s.play.pitchEnv.peakSemitones = -12.0; // an octave down = half speed at the peak
|
s.play.pitchEnv.peakSemitones = -12.0; // an octave down = half speed at the peak
|
||||||
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||||
at(120.0));
|
|
||||||
// Bounded at the deepest offset: 1 s of source can take up to 2 s to cross.
|
// Bounded at the deepest offset: 1 s of source can take up to 2 s to cross.
|
||||||
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
CHECK(near(r.captureEndSeconds, 2.0 + kPadSeconds));
|
||||||
|
|
||||||
// Preserve decouples pitch from the read rate, so the same dial bounds nothing.
|
// Preserve decouples pitch from the read rate, so the same dial bounds nothing.
|
||||||
s.play.pitchEngine = PitchEngine::Preserve;
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
const ResolvedNote kept = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
const ResolvedNote kept = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||||
at(120.0));
|
CHECK(near(kept.captureEndSeconds, 1.0 + kPadSeconds));
|
||||||
CHECK(kept.captureEndSeconds > 0.99 && kept.captureEndSeconds < 1.01);
|
}
|
||||||
|
|
||||||
|
// --- The bake fires at the velocity it is handed, and the Varispeed bound reads it ---
|
||||||
|
{
|
||||||
|
SampleData s = dialedSample(/*frames=*/kRate);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
|
// A bipolar velocity->pitch curve pulling a full octave down at velocity 0 and
|
||||||
|
// nothing at 127: the two velocities must therefore derive different windows.
|
||||||
|
s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
|
||||||
|
{{0.0, -0.5}, {127.0, 0.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
|
||||||
|
const NoteProgram soft =
|
||||||
|
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity::of(1));
|
||||||
|
const NoteProgram hard =
|
||||||
|
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity::of(127));
|
||||||
|
CHECK(soft.velocity.value() == 1);
|
||||||
|
CHECK(hard.velocity.value() == 127);
|
||||||
|
const ResolvedNote softR = resolveNote(soft, at(120.0));
|
||||||
|
const ResolvedNote hardR = resolveNote(hard, at(120.0));
|
||||||
|
CHECK(near(hardR.captureEndSeconds, 1.0 + kPadSeconds)); // no offset at 127
|
||||||
|
CHECK(softR.captureEndSeconds > hardR.captureEndSeconds * 1.9); // ~an octave down
|
||||||
|
// …and the velocity reaches the plan, which is what the render fires.
|
||||||
|
CHECK(planBake(softR, kRate, 60).plan->velocity == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gate with an ACTIVE sustain loop is the one case that needs Hold ---------------
|
||||||
|
{
|
||||||
|
SampleData s = dialedSample(/*frames=*/kRate);
|
||||||
|
s.play.playMode = PlayMode::Gate;
|
||||||
|
s.loop = SampleLoop{true, 0, kRate / 2};
|
||||||
|
CHECK(bakeWindowNeedsHold(s));
|
||||||
|
// The window follows Hold rather than the source, so a longer Hold is a longer file.
|
||||||
|
const ResolvedNote bar = resolveNote(
|
||||||
|
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity{}), at(120.0));
|
||||||
|
const ResolvedNote twoBars = resolveNote(
|
||||||
|
defaultBakeProgram(s, kRate, at(120.0),
|
||||||
|
makeDivision(3, DivisionModifier::Straight), Velocity{}),
|
||||||
|
at(120.0));
|
||||||
|
CHECK(near(bar.noteOffSeconds, 2.0));
|
||||||
|
CHECK(near(twoBars.noteOffSeconds, 4.0));
|
||||||
|
CHECK(near(twoBars.captureEndSeconds - bar.captureEndSeconds, 2.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The frame window and both event frames ----------------------------------
|
// --- The frame window and both event frames ----------------------------------
|
||||||
@@ -103,7 +163,7 @@ int main() {
|
|||||||
NoteProgram p; // 1/4 straight, velocity 100
|
NoteProgram p; // 1/4 straight, velocity 100
|
||||||
p.end = EndOffset(offsetFromMs(250.0));
|
p.end = EndOffset(offsetFromMs(250.0));
|
||||||
const ResolvedNote r = resolveNote(p, at(120.0)); // note-off 0.5 s, end 0.75 s
|
const ResolvedNote r = resolveNote(p, at(120.0)); // note-off 0.5 s, end 0.75 s
|
||||||
const auto plan = planBake(r, 48000, 60);
|
const auto plan = planBake(r, 48000, 60).plan;
|
||||||
CHECK(plan.has_value());
|
CHECK(plan.has_value());
|
||||||
CHECK(plan->totalFrames == 36000); // 0.75 s * 48 kHz
|
CHECK(plan->totalFrames == 36000); // 0.75 s * 48 kHz
|
||||||
CHECK(plan->leadInFrames == 0);
|
CHECK(plan->leadInFrames == 0);
|
||||||
@@ -121,7 +181,7 @@ int main() {
|
|||||||
p.start = StartOffset(offsetFromMs(-100.0)); // negative = earlier
|
p.start = StartOffset(offsetFromMs(-100.0)); // negative = earlier
|
||||||
p.end = EndOffset(offsetFromMs(100.0));
|
p.end = EndOffset(offsetFromMs(100.0));
|
||||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
||||||
const auto plan = planBake(r, 44100, 60);
|
const auto plan = planBake(r, 44100, 60).plan;
|
||||||
CHECK(plan.has_value());
|
CHECK(plan.has_value());
|
||||||
// Window is [-0.1, 0.6] s = 0.7 s; note-on sits 0.1 s in, note-off 0.5 s after it.
|
// Window is [-0.1, 0.6] s = 0.7 s; note-on sits 0.1 s in, note-off 0.5 s after it.
|
||||||
CHECK(plan->totalFrames == 30870);
|
CHECK(plan->totalFrames == 30870);
|
||||||
@@ -137,7 +197,7 @@ int main() {
|
|||||||
p.start = StartOffset(offsetFromMs(100.0)); // positive = later: the head is cut
|
p.start = StartOffset(offsetFromMs(100.0)); // positive = later: the head is cut
|
||||||
p.end = EndOffset(offsetFromMs(100.0));
|
p.end = EndOffset(offsetFromMs(100.0));
|
||||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
||||||
const auto plan = planBake(r, 48000, 60);
|
const auto plan = planBake(r, 48000, 60).plan;
|
||||||
CHECK(plan.has_value());
|
CHECK(plan.has_value());
|
||||||
// Window is [0.1, 0.6] s = 0.5 s of FILE, but the note starts 0.1 s before it, so
|
// Window is [0.1, 0.6] s = 0.5 s of FILE, but the note starts 0.1 s before it, so
|
||||||
// the render must produce that head and throw it away rather than shift the note.
|
// the render must produce that head and throw it away rather than shift the note.
|
||||||
@@ -149,20 +209,20 @@ int main() {
|
|||||||
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
|
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Refusals -----------------------------------------------------------------
|
// --- Refusals, and which one each condition reports -----------------------------
|
||||||
{
|
{
|
||||||
NoteProgram p;
|
NoteProgram p;
|
||||||
// An end offset more negative than the note length inverts the window.
|
// An end offset more negative than the note length inverts the window.
|
||||||
p.end = EndOffset(offsetFromMs(-10000.0));
|
p.end = EndOffset(offsetFromMs(-10000.0));
|
||||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
||||||
CHECK(r.windowCollapsed);
|
CHECK(r.windowCollapsed);
|
||||||
CHECK(!planBake(r, 48000, 60).has_value());
|
CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
NoteProgram plain;
|
NoteProgram plain;
|
||||||
const ResolvedNote r = resolveNote(plain, at(120.0));
|
const ResolvedNote r = resolveNote(plain, at(120.0));
|
||||||
CHECK(!planBake(r, 0, 60).has_value());
|
CHECK(planBake(r, 0, 60).refusal == BakeRefusal::EmptyWindow);
|
||||||
CHECK(!planBake(r, -48000, 60).has_value());
|
CHECK(planBake(r, -48000, 60).refusal == BakeRefusal::EmptyWindow);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// A legal but sub-frame window rounds to nothing and is refused rather than
|
// A legal but sub-frame window rounds to nothing and is refused rather than
|
||||||
@@ -172,34 +232,39 @@ int main() {
|
|||||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
||||||
CHECK(!r.windowCollapsed);
|
CHECK(!r.windowCollapsed);
|
||||||
CHECK(r.captureLengthSeconds() == 0.0);
|
CHECK(r.captureLengthSeconds() == 0.0);
|
||||||
CHECK(!planBake(r, 48000, 60).has_value());
|
CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// A legal offset magnitude reaches days: refused at the ceiling, not attempted as
|
// A legal offset magnitude reaches days: refused at the ceiling, not attempted as
|
||||||
// an allocation (and never narrowed out of int64's range on the way there).
|
// an allocation (and never narrowed out of int64's range on the way there). This
|
||||||
|
// refusal reads differently to the user — a real sound that will not fit, not an
|
||||||
|
// empty window — so it must be a distinct value, not just a nullopt.
|
||||||
const double overSeconds =
|
const double overSeconds =
|
||||||
(static_cast<double>(kMaxBakeFrames) / 48000.0) + 1.0;
|
(static_cast<double>(kMaxBakeFrames) / 48000.0) + 1.0;
|
||||||
NoteProgram p;
|
NoteProgram p;
|
||||||
p.end = EndOffset(offsetFromMs(overSeconds * 1000.0));
|
p.end = EndOffset(offsetFromMs(overSeconds * 1000.0));
|
||||||
const ResolvedNote big = resolveNote(p, at(120.0));
|
const ResolvedNote big = resolveNote(p, at(120.0));
|
||||||
CHECK(!big.windowCollapsed);
|
CHECK(!big.windowCollapsed);
|
||||||
CHECK(!planBake(big, 48000, 60).has_value());
|
CHECK(planBake(big, 48000, 60).refusal == BakeRefusal::PastFrameCeiling);
|
||||||
|
|
||||||
// The extreme a legal OffsetAmount can hold, in both directions.
|
// The extreme a legal OffsetAmount can hold, in both directions.
|
||||||
NoteProgram huge;
|
NoteProgram huge;
|
||||||
huge.end = EndOffset(offsetFromMs(kMaxConvertibleMagnitude));
|
huge.end = EndOffset(offsetFromMs(kMaxConvertibleMagnitude));
|
||||||
CHECK(!planBake(resolveNote(huge, at(120.0)), 48000, 60).has_value());
|
CHECK(planBake(resolveNote(huge, at(120.0)), 48000, 60).refusal ==
|
||||||
|
BakeRefusal::PastFrameCeiling);
|
||||||
NoteProgram far;
|
NoteProgram far;
|
||||||
far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
|
far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
|
||||||
CHECK(!planBake(resolveNote(far, at(120.0)), 48000, 60).has_value());
|
CHECK(planBake(resolveNote(far, at(120.0)), 48000, 60).refusal ==
|
||||||
|
BakeRefusal::PastFrameCeiling);
|
||||||
|
|
||||||
// And just under it still plans, so the ceiling is a bound, not a blanket refusal.
|
// And just under it still plans, so the ceiling is a bound, not a blanket refusal.
|
||||||
NoteProgram fits;
|
NoteProgram fits;
|
||||||
fits.end = EndOffset(offsetFromMs(
|
fits.end = EndOffset(offsetFromMs(
|
||||||
(static_cast<double>(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0));
|
(static_cast<double>(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0));
|
||||||
const auto planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
|
const PlannedBake planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
|
||||||
CHECK(planned.has_value());
|
CHECK(planned.plan.has_value());
|
||||||
CHECK(planned && planned->renderFrames() <= kMaxBakeFrames);
|
CHECK(planned.refusal == BakeRefusal::None);
|
||||||
|
CHECK(planned.plan && planned.plan->renderFrames() <= kMaxBakeFrames);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Domain clamps -------------------------------------------------------------
|
// --- Domain clamps -------------------------------------------------------------
|
||||||
@@ -207,9 +272,9 @@ int main() {
|
|||||||
NoteProgram p;
|
NoteProgram p;
|
||||||
p.end = EndOffset(offsetFromMs(100.0));
|
p.end = EndOffset(offsetFromMs(100.0));
|
||||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
||||||
const auto low = planBake(r, 48000, -5);
|
const auto low = planBake(r, 48000, -5).plan;
|
||||||
const auto high = planBake(r, 48000, 900);
|
const auto high = planBake(r, 48000, 900).plan;
|
||||||
const auto mid = planBake(r, 48000, 60);
|
const auto mid = planBake(r, 48000, 60).plan;
|
||||||
CHECK(low.has_value() && high.has_value() && mid.has_value());
|
CHECK(low.has_value() && high.has_value() && mid.has_value());
|
||||||
if (low && high && mid) {
|
if (low && high && mid) {
|
||||||
CHECK(low->note == 0);
|
CHECK(low->note == 0);
|
||||||
|
|||||||
+163
-102
@@ -1,11 +1,12 @@
|
|||||||
// Standalone audit of the DERIVED bake window: does defaultBakeProgram's window hold the
|
// The DERIVED bake window, end to end: does defaultBakeProgram's window hold the whole
|
||||||
// whole audible result of the dialed sound, in both play modes? Every case renders through
|
// audible result of the dialed sound? Every case renders through the real chain
|
||||||
// the real chain (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then
|
// (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then re-renders the SAME
|
||||||
// re-renders the SAME sound with a longer window, so "what fell outside" is measured rather
|
// sound with a longer window, so "what fell outside" is measured rather than argued.
|
||||||
// than argued. Trailing silence is a pass; signal past the derived end is a truncation.
|
// Trailing silence is a pass; signal past the derived end is a truncation.
|
||||||
|
|
||||||
#include "../src/core/instrument/bake/bake_plan.h"
|
#include "../src/core/instrument/bake/bake_plan.h"
|
||||||
#include "../src/core/instrument/bake/bake_render.h"
|
#include "../src/core/instrument/bake/bake_render.h"
|
||||||
|
#include "../src/core/instrument/engine/voice.h" // kDeclickFrames (the pad under test)
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -25,6 +26,14 @@ constexpr double kBpm = 120.0; // a quarter note is 0.5 s == 24000 frames
|
|||||||
constexpr double kUnity = 1.0;
|
constexpr double kUnity = 1.0;
|
||||||
constexpr double kSilence = 1e-6;
|
constexpr double kSilence = 1e-6;
|
||||||
|
|
||||||
|
// The declick pad every derived window carries. Read off the engine's own constants, so a
|
||||||
|
// retuned ramp moves this file's expectations with it rather than against them.
|
||||||
|
constexpr std::int64_t kPad = kDeclickFrames;
|
||||||
|
|
||||||
|
// One bar at 120 BPM == 2 s == 96000 frames. The Hold default, spelled out so the
|
||||||
|
// expectations below read as arithmetic rather than as magic.
|
||||||
|
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
|
||||||
|
|
||||||
Tempo tempo() {
|
Tempo tempo() {
|
||||||
const std::optional<Tempo> t = Tempo::fromBpm(kBpm);
|
const std::optional<Tempo> t = Tempo::fromBpm(kBpm);
|
||||||
if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; }
|
if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; }
|
||||||
@@ -53,125 +62,143 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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), `length` overrides the programmed note length. Both leave the
|
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
|
||||||
// derivation itself untouched, which is what makes the comparison a measurement of the
|
// comparison a measurement of the derived end rather than of a second derivation.
|
||||||
// derived end rather than of a second derivation.
|
NoteProgram derivedProgram(const SampleData& s, double extraMs, Division hold = oneBar(),
|
||||||
NoteProgram derivedProgram(const SampleData& s, double extraMs) {
|
int velocity = 100) {
|
||||||
NoteProgram p = defaultBakeProgram(s, kRate, tempo());
|
NoteProgram p = defaultBakeProgram(s, kRate, tempo(), hold, Velocity::of(velocity));
|
||||||
if (extraMs != 0.0)
|
if (extraMs != 0.0)
|
||||||
p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
|
p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<BakePlan> planOf(const NoteProgram& p) {
|
std::optional<BakePlan> planOf(const NoteProgram& p) {
|
||||||
return planBake(resolveNote(p, tempo()), kRate, 60);
|
return planBake(resolveNote(p, tempo()), kRate, 60).plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The render the shell would produce, plus `extraMs` of extra window.
|
// The render the shell would produce, plus `extraMs` of extra window.
|
||||||
BakeAudio bakeWith(const SampleData& s, double extraMs) {
|
BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar(),
|
||||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs));
|
int velocity = 100) {
|
||||||
|
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs, hold, velocity));
|
||||||
if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; }
|
if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; }
|
||||||
return renderBake(s, *plan, kUnity);
|
return renderBake(s, *plan, kUnity);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::int64_t derivedFrames(const SampleData& s) {
|
std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
|
||||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0));
|
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0, hold));
|
||||||
return plan ? plan->totalFrames : -1;
|
return plan ? plan->totalFrames : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The last frame of the file, which is where a hard cut shows up.
|
||||||
|
double lastFrameLevel(const BakeAudio& audio) {
|
||||||
|
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
|
||||||
|
: 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
// ================================ GATE ==========================================
|
// ================================ GATE ==========================================
|
||||||
|
|
||||||
// --- Gate, no loop, staged AHDSR: the derived end is EXACT on the release ----------
|
// --- Gate, no loop: the note is held until the SOURCE runs out ---------------------
|
||||||
// note-off at the quarter note, release_frames after it, and not one frame of signal
|
// The read head frees the voice at exhaustion whether or not the gate is still down, so
|
||||||
// past that — the end offset is the release, so the two must coincide exactly.
|
// the maximal sound is the whole take. 2 s of source == 4 beats, exactly one bar.
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(96000);
|
SampleData s = dcSample(96000);
|
||||||
s.play.playMode = PlayMode::Gate;
|
s.play.playMode = PlayMode::Gate;
|
||||||
s.play.adsr.releaseFrames = 4800; // 100 ms
|
s.play.adsr.releaseFrames = 4800; // 100 ms
|
||||||
|
|
||||||
CHECK(derivedFrames(s) == 28800); // 24000 (quarter) + 4800 (release)
|
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
||||||
|
|
||||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
|
||||||
CHECK(wide.frameCount() == 38400);
|
// Full level right up to exhaustion, and nothing at all past it — the note outlived
|
||||||
// Still releasing on the last ten frames the derived window would have kept…
|
// its own release, so the release window is trailing silence, not a truncated tail.
|
||||||
CHECK(peakAt(wide, 28790, 28800) > kSilence);
|
CHECK(peakAt(wide, 95000, 96000) > 0.4);
|
||||||
// …and EXACTLY silent from there on: nothing was cut.
|
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);
|
||||||
CHECK(peakAt(wide, 28800, 38400) == 0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Gate with a sustain loop: there is NO intrinsic end to derive -----------------
|
// --- Gate, no loop, a SLOW ATTACK: the derived note reaches the dialed peak ---------
|
||||||
// The window holds the whole programmed note, but the note length itself is a constant
|
// The window used to be a constant quarter note, which released a 2 s attack a quarter of
|
||||||
// quarter note — hold the same dialed sound longer and it keeps sounding, indefinitely.
|
// the way up. Deriving the hold from source exhaustion is what closes that.
|
||||||
// This is the one number a derivation cannot supply.
|
{
|
||||||
|
SampleData s = dcSample(192000); // 4 s
|
||||||
|
s.play.playMode = PlayMode::Gate;
|
||||||
|
s.play.adsr.attackFrames = 96000; // 2 s
|
||||||
|
s.play.adsr.releaseFrames = 0;
|
||||||
|
|
||||||
|
CHECK(derivedFrames(s) == 192000 + kPad);
|
||||||
|
const BakeAudio derived = bakeWith(s, 0.0);
|
||||||
|
// The whole attack, at full level — under the old constant quarter note this peaked
|
||||||
|
// between 0.10 and 0.14.
|
||||||
|
CHECK(peakAt(derived, 0, derived.frameCount()) > 0.49);
|
||||||
|
// …and a longer window adds nothing: the derivation already held everything.
|
||||||
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
|
||||||
|
CHECK(peakAt(wide, 192000, wide.frameCount()) == 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gate WITH a sustain loop: Hold is the note length, and the ONLY user input ------
|
||||||
|
// A looped Gate voice sounds for as long as it is held, so no derivation supplies a
|
||||||
|
// duration — this is the one case the predicate names, and the window follows Hold.
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(48000);
|
SampleData s = dcSample(48000);
|
||||||
s.loop = SampleLoop{true, 0, 24000};
|
s.loop = SampleLoop{true, 0, 24000};
|
||||||
s.play.playMode = PlayMode::Gate;
|
s.play.playMode = PlayMode::Gate;
|
||||||
s.play.adsr.releaseFrames = 4800;
|
s.play.adsr.releaseFrames = 4800;
|
||||||
|
|
||||||
const BakeAudio derived = bakeWith(s, 0.0);
|
CHECK(bakeWindowNeedsHold(s));
|
||||||
CHECK(derived.frameCount() == 28800);
|
|
||||||
// The derived window does bound the render: full level while the note is held, and
|
|
||||||
// down to the release's own floor at the end — the loop is not left cycling.
|
|
||||||
CHECK(peakAt(derived, 20000, 24000) > 0.4);
|
|
||||||
CHECK(peakAt(derived, 28790, 28800) < 0.01);
|
|
||||||
|
|
||||||
// The SAME dialed sound held for a whole note instead of a quarter: four times the
|
// One bar at 120 BPM == 96000 frames, plus the release, plus the pad.
|
||||||
// audio, all of it real, none of it reachable from the derivation.
|
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
||||||
NoteProgram longer = derivedProgram(s, 0.0);
|
const BakeAudio bar = bakeWith(s, 0.0);
|
||||||
longer.length = makeDivision(2, DivisionModifier::Straight); // 4 beats == 2 s
|
CHECK(peakAt(bar, 90000, 96000) > 0.4); // still cycling at note-off
|
||||||
const std::optional<BakePlan> plan = planOf(longer);
|
CHECK(peakAt(bar, 96000 + 4790, 96000 + 4800) < 0.01); // released to its own floor
|
||||||
CHECK(plan.has_value());
|
|
||||||
const BakeAudio held = renderBake(s, *plan, kUnity);
|
// A different Hold is a different window, in the same proportion — the knob really is
|
||||||
CHECK(held.frameCount() == 100800); // 96000 + 4800
|
// what the derivation reads here.
|
||||||
CHECK(peakAt(held, 28800, 96000) > 0.4); // full level, well past the derived end
|
const Division twoBars = makeDivision(3, DivisionModifier::Straight);
|
||||||
|
CHECK(derivedFrames(s, twoBars) == 192000 + 4800 + kPad);
|
||||||
|
const BakeAudio held = bakeWith(s, 0.0, twoBars);
|
||||||
|
CHECK(peakAt(held, 96000, 192000) > 0.4); // full level well past one bar
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Gate: the constant quarter-note hold TRUNCATES a slow attack ------------------
|
// --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
|
||||||
// The window is correct for the note it programs; the note is what is wrong. A 2 s
|
|
||||||
// attack never reaches its peak, so the bake prints a sound the user never dialed.
|
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(192000);
|
SampleData s = dcSample(48000);
|
||||||
s.play.playMode = PlayMode::Gate;
|
s.play.playMode = PlayMode::Gate;
|
||||||
s.play.adsr.attackFrames = 96000; // 2 s, four times the programmed note
|
CHECK(!bakeWindowNeedsHold(s)); // no loop at all
|
||||||
s.play.adsr.releaseFrames = 0;
|
s.loop = SampleLoop{true, 0, 24000};
|
||||||
|
CHECK(bakeWindowNeedsHold(s));
|
||||||
const BakeAudio derived = bakeWith(s, 0.0);
|
s.loop = SampleLoop{true, 0, 48001}; // reaches past the PCM
|
||||||
CHECK(derived.frameCount() == 24000);
|
CHECK(!bakeWindowNeedsHold(s)); // …which resolveLoop refuses
|
||||||
// A quarter of the way up the attack at most.
|
s.loop = SampleLoop{true, 24000, 12000}; // inverted
|
||||||
const double derivedPeak = peakAt(derived, 0, 24000);
|
CHECK(!bakeWindowNeedsHold(s));
|
||||||
CHECK(derivedPeak > 0.1 && derivedPeak < 0.14);
|
s.loop = SampleLoop{true, 0, 24000};
|
||||||
|
s.play.playMode = PlayMode::Trigger; // Trigger has no sustain loop
|
||||||
NoteProgram longer = derivedProgram(s, 0.0);
|
CHECK(!bakeWindowNeedsHold(s));
|
||||||
longer.length = makeDivision(3, DivisionModifier::Straight); // 8 beats == 4 s
|
|
||||||
const std::optional<BakePlan> plan = planOf(longer);
|
|
||||||
CHECK(plan.has_value());
|
|
||||||
const BakeAudio held = renderBake(s, *plan, kUnity);
|
|
||||||
CHECK(peakAt(held, 0, held.frameCount()) > 0.49); // the dialed sound, in full
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Gate + Preserve: the terminal ring-out is cut when the source ends AT the window
|
// --- Gate + Preserve: the terminal ring-out is INSIDE the window ---------------------
|
||||||
// Preserve rings its last real output out (~4 ms) instead of hard-cutting it; that ramp
|
// Preserve rings its last real output out instead of hard-cutting it; the derived window
|
||||||
// lands past the derived end whenever the voice's own end lands on it.
|
// is padded by exactly that ramp, so the file ends at silence.
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(24000); // exhausts exactly at the quarter-note note-off
|
SampleData s = dcSample(24000); // exhausts at half a bar
|
||||||
s.play.playMode = PlayMode::Gate;
|
s.play.playMode = PlayMode::Gate;
|
||||||
s.play.pitchEngine = PitchEngine::Preserve;
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
s.play.adsr.releaseFrames = 0;
|
s.play.adsr.releaseFrames = 0;
|
||||||
|
|
||||||
CHECK(derivedFrames(s) == 24000);
|
CHECK(derivedFrames(s) == 24000 + kPad);
|
||||||
|
const BakeAudio derived = bakeWith(s, 0.0);
|
||||||
|
CHECK(peakAt(derived, 23990, 24000) > 0.4); // full level while the source lasts
|
||||||
|
CHECK(peakAt(derived, 24000, 24000 + kPad) > 0.05); // the ramp, inside the file
|
||||||
|
CHECK(lastFrameLevel(derived) < 1e-3); // and the file ends at silence
|
||||||
|
// Nothing at all past the pad: the window is not merely long, it is exactly enough.
|
||||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
||||||
CHECK(peakAt(wide, 23990, 24000) > 0.4); // hard cut at full level…
|
CHECK(peakAt(wide, 24000 + kPad, wide.frameCount()) == 0.0);
|
||||||
CHECK(peakAt(wide, 24000, 24100) > 0.05); // …with the ring-out outside the window
|
|
||||||
CHECK(peakAt(wide, 24400, wide.frameCount()) < kSilence); // and it is bounded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The resonant filter cannot ring past the amp gate ------------------------------
|
// --- The resonant filter cannot ring past the amp gate ------------------------------
|
||||||
// pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is
|
// pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is
|
||||||
// gated by the release the window already holds. Not a tail contributor.
|
// gated by the envelope the window already holds. Not a tail contributor.
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(96000);
|
SampleData s = dcSample(96000);
|
||||||
s.play.playMode = PlayMode::Gate;
|
s.play.playMode = PlayMode::Gate;
|
||||||
@@ -181,8 +208,8 @@ int main() {
|
|||||||
s.play.filter.settings.resonanceNorm = 1.0f;
|
s.play.filter.settings.resonanceNorm = 1.0f;
|
||||||
|
|
||||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
|
||||||
CHECK(peakAt(wide, 0, 28800) > kSilence); // the filtered note sounded
|
CHECK(peakAt(wide, 0, 96000) > kSilence); // the filtered note sounded
|
||||||
CHECK(peakAt(wide, 28800, wide.frameCount()) == 0.0); // and nothing rang past it
|
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0); // and nothing rang past it
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================ TRIGGER =======================================
|
// ================================ TRIGGER =======================================
|
||||||
@@ -199,7 +226,7 @@ int main() {
|
|||||||
s.play.pitchEnv.shape.holdFraction = 1.0; // held down for the whole span
|
s.play.pitchEnv.shape.holdFraction = 1.0; // held down for the whole span
|
||||||
|
|
||||||
// 1 s of source stretched by 2^(24/12) == 4.
|
// 1 s of source stretched by 2^(24/12) == 4.
|
||||||
CHECK(derivedFrames(s) == 192000);
|
CHECK(derivedFrames(s) == 192000 + kPad);
|
||||||
const BakeAudio derived = bakeWith(s, 0.0);
|
const BakeAudio derived = bakeWith(s, 0.0);
|
||||||
// The offset only holds for the envelope's own span, so the read finishes near
|
// The offset only holds for the envelope's own span, so the read finishes near
|
||||||
// 84000 frames — inside the window, with the balance as trailing silence.
|
// 84000 frames — inside the window, with the balance as trailing silence.
|
||||||
@@ -207,50 +234,72 @@ int main() {
|
|||||||
CHECK(peakAt(derived, 90000, 192000) < kSilence);
|
CHECK(peakAt(derived, 90000, 192000) < kSilence);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Trigger + Preserve (the product-default engine): the ring-out is TRUNCATED -----
|
// --- Trigger + Preserve (the product-default engine): the ring-out is HELD -----------
|
||||||
// Preserve's read reaches playEnd on the exact frame the derived window closes, and the
|
// Preserve's read reaches playEnd where the un-padded window used to close, leaving the
|
||||||
// ~4 ms terminal declick that follows is entirely outside it. The baked file therefore
|
// whole terminal declick outside it — a hard cut at full level, the very click the ramp
|
||||||
// ends on a hard cut at full level — reintroducing the click the ramp exists to remove.
|
// exists to remove. The pad is what closes that.
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(48000);
|
SampleData s = dcSample(48000);
|
||||||
s.play.playMode = PlayMode::Trigger;
|
s.play.playMode = PlayMode::Trigger;
|
||||||
s.play.pitchEngine = PitchEngine::Preserve;
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
|
||||||
CHECK(derivedFrames(s) == 48000);
|
CHECK(derivedFrames(s) == 48000 + kPad);
|
||||||
|
const BakeAudio derived = bakeWith(s, 0.0);
|
||||||
|
CHECK(peakAt(derived, 47990, 48000) > 0.4); // full level at the source's own end
|
||||||
|
CHECK(peakAt(derived, 48000, 48001) > 0.49); // the ramp opens at that same level…
|
||||||
|
CHECK(lastFrameLevel(derived) < 1e-3); // …and the file ends at silence
|
||||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
||||||
CHECK(peakAt(wide, 47990, 48000) > 0.4); // full level on the derived last frame
|
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
|
||||||
CHECK(peakAt(wide, 48000, 48100) > 0.05); // real signal past the derived end
|
|
||||||
// Measured: the ramp opens at the last in-window level (0.500) and reaches exact
|
|
||||||
// zero 180 frames later — the whole 3.75 ms sits outside the derived window.
|
|
||||||
CHECK(peakAt(wide, 48000, 48001) > 0.49);
|
|
||||||
CHECK(peakAt(wide, 48179, 48180) > 0.0);
|
|
||||||
CHECK(peakAt(wide, 48180, wide.frameCount()) == 0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Trigger + a DRAWN amp EG: the window keeps a %-length the engine IGNORES -------
|
// --- Trigger + a DRAWN amp EG: the window holds the WHOLE take -----------------------
|
||||||
// A drawn contour covers the full sample length, so Voice::start forces lengthFraction
|
// A drawn contour covers the full sample length, so the engine folds lengthFraction to
|
||||||
// to 1.0 (splineActive, play_params.h) — but defaultBakeProgram reads the stored,
|
// 1.0 (effectiveLengthFraction, trigger_seam.h). The stored %-knob is inert but still
|
||||||
// UI-inert knob straight through triggerPlayLength. The window is a quarter of the take.
|
// saved, and reading it raw here cut this window to a quarter of the take.
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(48000);
|
SampleData s = dcSample(48000);
|
||||||
s.play.playMode = PlayMode::Trigger;
|
s.play.playMode = PlayMode::Trigger;
|
||||||
s.play.trigger.lengthFraction = 0.25; // inert in the engine, live in the derivation
|
s.play.trigger.lengthFraction = 0.25; // inert in the engine, and now in the window
|
||||||
s.play.ampSpline.mode = EnvMode::Spline;
|
s.play.ampSpline.mode = EnvMode::Spline;
|
||||||
s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample
|
s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample
|
||||||
|
|
||||||
CHECK(derivedFrames(s) == 12000); // 0.25 s of a 1 s take
|
CHECK(derivedFrames(s) == 48000 + kPad); // the whole take, not 12000
|
||||||
|
|
||||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
|
const BakeAudio derived = bakeWith(s, 0.0);
|
||||||
// The engine really did play the whole sample: full level right up to 48000…
|
// Full level across the 36000 frames a raw read of the stored knob used to cut. Both
|
||||||
CHECK(peakAt(wide, 12000, 48000) > 0.4);
|
// spans start past the old 12000-frame end, so a truncated window reads 0 here.
|
||||||
CHECK(peakAt(wide, 47000, 48000) > 0.4);
|
CHECK(peakAt(derived, 36000, 40000) > 0.4);
|
||||||
// …and stopped at its own span end, so the 36000 lost frames are the take, not tail.
|
CHECK(peakAt(derived, 47000, 48000) > 0.4);
|
||||||
CHECK(peakAt(wide, 48200, wide.frameCount()) < kSilence);
|
// A %-knob that IS live still shortens the window — the fold is conditional, not a
|
||||||
|
// blanket ignore.
|
||||||
|
SampleData staged = s;
|
||||||
|
staged.play.ampSpline.mode = EnvMode::Staged;
|
||||||
|
CHECK(derivedFrames(staged) == 12000 + kPad);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- A legitimate dialed sound past the frame ceiling is REFUSED, not cut -----------
|
// ============================== VELOCITY ========================================
|
||||||
// 1.1e6 source frames stretched by 2^(48/12) == 16 exceeds kMaxBakeFrames. The refusal
|
|
||||||
// is the safe direction (the shell reports it), but it is a sound that cannot be baked.
|
// --- The bake renders at the velocity it is handed ----------------------------------
|
||||||
|
// Three velocity curves are live, so a sound auditioned at 120 does not bake as one
|
||||||
|
// auditioned at 40. linear() maps velocity/127 onto amp gain.
|
||||||
|
{
|
||||||
|
SampleData s = dcSample(24000);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
s.velocityCurve = VelocityCurve::linear();
|
||||||
|
|
||||||
|
const BakeAudio soft = bakeWith(s, 0.0, oneBar(), /*velocity=*/40);
|
||||||
|
const BakeAudio hard = bakeWith(s, 0.0, oneBar(), /*velocity=*/120);
|
||||||
|
const double softPeak = peakAt(soft, 0, 24000);
|
||||||
|
const double hardPeak = peakAt(hard, 0, 24000);
|
||||||
|
// 0.5 * 40/127 == 0.157, 0.5 * 120/127 == 0.472.
|
||||||
|
CHECK(softPeak > 0.15 && softPeak < 0.17);
|
||||||
|
CHECK(hardPeak > 0.46 && hardPeak < 0.48);
|
||||||
|
CHECK(hardPeak > softPeak * 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== REFUSALS ========================================
|
||||||
|
|
||||||
|
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
|
||||||
{
|
{
|
||||||
SampleData s = dcSample(1'100'000);
|
SampleData s = dcSample(1'100'000);
|
||||||
s.play.playMode = PlayMode::Trigger;
|
s.play.playMode = PlayMode::Trigger;
|
||||||
@@ -258,7 +307,10 @@ int main() {
|
|||||||
s.play.pitchEnv.enabled = true;
|
s.play.pitchEnv.enabled = true;
|
||||||
s.play.pitchEnv.peakSemitones = -48.0;
|
s.play.pitchEnv.peakSemitones = -48.0;
|
||||||
s.play.pitchEnv.shape.holdFraction = 1.0;
|
s.play.pitchEnv.shape.holdFraction = 1.0;
|
||||||
CHECK(!planOf(derivedProgram(s, 0.0)).has_value());
|
const PlannedBake tooLong =
|
||||||
|
planBake(resolveNote(derivedProgram(s, 0.0), tempo()), kRate, 60);
|
||||||
|
CHECK(!tooLong.plan.has_value());
|
||||||
|
CHECK(tooLong.refusal == BakeRefusal::PastFrameCeiling);
|
||||||
|
|
||||||
// One octave shallower is inside the ceiling — the refusal above is the window, not
|
// One octave shallower is inside the ceiling — the refusal above is the window, not
|
||||||
// the fixture.
|
// the fixture.
|
||||||
@@ -266,6 +318,15 @@ int main() {
|
|||||||
CHECK(planOf(derivedProgram(s, 0.0)).has_value());
|
CHECK(planOf(derivedProgram(s, 0.0)).has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- An empty window is a DIFFERENT refusal, so the shell can say a different thing ---
|
||||||
|
{
|
||||||
|
NoteProgram p;
|
||||||
|
p.end = EndOffset(offsetFromMs(-5000.0)); // ends long before note-off: collapsed
|
||||||
|
const PlannedBake empty = planBake(resolveNote(p, tempo()), kRate, 60);
|
||||||
|
CHECK(!empty.plan.has_value());
|
||||||
|
CHECK(empty.refusal == BakeRefusal::EmptyWindow);
|
||||||
|
}
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("bake_window: all tests passed\n");
|
if (g_fail == 0) std::printf("bake_window: all tests passed\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
using namespace reasampler;
|
using namespace reasampler;
|
||||||
using namespace reasampler::instrument::map;
|
using namespace reasampler::instrument::map;
|
||||||
|
namespace note = reasampler::instrument::note; // the bake Hold's ladder
|
||||||
|
|
||||||
static int failures = 0;
|
static int failures = 0;
|
||||||
|
|
||||||
@@ -452,7 +453,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,0x0d,0x00,0x00,
|
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0e,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,
|
||||||
@@ -545,6 +546,9 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points)
|
0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points)
|
||||||
0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags
|
0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags
|
||||||
0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags
|
0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags
|
||||||
|
// --- payload v14 bake Hold, at its one-bar default ---
|
||||||
|
0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1)
|
||||||
|
0x00, // Straight
|
||||||
};
|
};
|
||||||
// clang-format on
|
// clang-format on
|
||||||
CHECK(bytes.size() == sizeof(kGolden));
|
CHECK(bytes.size() == sizeof(kGolden));
|
||||||
@@ -592,18 +596,19 @@ static void testEnvelopePrefixBytesFrozen() {
|
|||||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||||
}
|
}
|
||||||
CHECK(kComponentStateVersion == 11);
|
CHECK(kComponentStateVersion == 11);
|
||||||
CHECK(kParamsPayloadVersion == 13);
|
CHECK(kParamsPayloadVersion == 14);
|
||||||
CHECK(kParamsSingleRecordVersion == 8);
|
CHECK(kParamsSingleRecordVersion == 8);
|
||||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||||
// The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not
|
// The filter, staged-curve, loop, velocity, spline and bake-Hold tails rode PAYLOAD bumps,
|
||||||
// envelope ones — the two axes stay independent, so a future envelope field cannot collide
|
// not envelope ones — the two axes stay independent, so a future envelope field cannot
|
||||||
// with any of them on one number.
|
// collide with any of them on one number.
|
||||||
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||||
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
||||||
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
||||||
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
||||||
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
|
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
|
||||||
CHECK(kParamsPayloadVersion == kParamsSplineVersion);
|
CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
|
||||||
|
CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The filter tail (payload v9) --------------------------------------------
|
// --- The filter tail (payload v9) --------------------------------------------
|
||||||
@@ -782,6 +787,19 @@ static void testNonFiniteAhdSecondsLiftToZero() {
|
|||||||
|
|
||||||
// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------
|
// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------
|
||||||
|
|
||||||
|
// The two trailing blocks of a CURRENT blob, so the splice tests below can cut back to the
|
||||||
|
// hard flags and rewrite them without hand-counting the payload twice. Every velocity curve
|
||||||
|
// in those fixtures is at its default 2-point shape, which is what pins the flag block sizes.
|
||||||
|
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
|
||||||
|
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
|
||||||
|
|
||||||
|
// The v14 tail at its one-bar default, re-appended after a splice so the record still ends
|
||||||
|
// where the reader expects it to.
|
||||||
|
static void putDefaultBakeHoldTail(std::vector<std::uint8_t>& out) {
|
||||||
|
legacy::u32v(out, 2); // quarterExponent 2 == 1/1
|
||||||
|
legacy::u8v(out, 0); // Straight
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
|
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
|
||||||
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
|
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
|
||||||
@@ -814,8 +832,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() >= 18);
|
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
|
||||||
bytes.resize(bytes.size() - 18); // drop the three well-formed 4+2-byte blocks
|
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
|
||||||
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
|
||||||
@@ -824,6 +842,7 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
|
|||||||
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
|
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
|
||||||
legacy::u8v(bytes, 0);
|
legacy::u8v(bytes, 0);
|
||||||
legacy::u8v(bytes, 0);
|
legacy::u8v(bytes, 0);
|
||||||
|
putDefaultBakeHoldTail(bytes);
|
||||||
|
|
||||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
// Every param preceding AND following the corrupted amp tail survives untouched.
|
// Every param preceding AND following the corrupted amp tail survives untouched.
|
||||||
@@ -862,8 +881,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() >= 18);
|
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
|
||||||
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
|
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
|
||||||
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
|
||||||
// No amp flag bytes follow — bound-and-skip must consume none, so the well-formed
|
// No amp flag bytes follow — bound-and-skip must consume none, so the well-formed
|
||||||
// filter/pitch blocks right after it land exactly where they belong.
|
// filter/pitch blocks right after it land exactly where they belong.
|
||||||
@@ -873,6 +892,7 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
|
|||||||
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
|
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
|
||||||
legacy::u8v(bytes, 0);
|
legacy::u8v(bytes, 0);
|
||||||
legacy::u8v(bytes, 0);
|
legacy::u8v(bytes, 0);
|
||||||
|
putDefaultBakeHoldTail(bytes);
|
||||||
|
|
||||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
// The whole record survives — including everything the v13 section itself carries ahead of
|
// The whole record survives — including everything the v13 section itself carries ahead of
|
||||||
@@ -904,8 +924,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() >= 18);
|
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
|
||||||
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
|
// Drops the bake-Hold tail with the flags: the truncation strands everything after it,
|
||||||
|
// which is the whole point — Hold lifts to its default alongside the flags.
|
||||||
|
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
|
||||||
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);
|
||||||
|
|
||||||
@@ -918,6 +940,7 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
|
|||||||
CHECK(out.params.loopCrossfadeFrames == 5);
|
CHECK(out.params.loopCrossfadeFrames == 5);
|
||||||
CHECK(out.params.velocityCurve.size() == 2);
|
CHECK(out.params.velocityCurve.size() == 2);
|
||||||
CHECK(!out.params.velocityCurve.points()[0].hard);
|
CHECK(!out.params.velocityCurve.points()[0].hard);
|
||||||
|
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The loop tail (payload v11) ---------------------------------------------
|
// --- The loop tail (payload v11) ---------------------------------------------
|
||||||
@@ -1145,6 +1168,98 @@ static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
|
|||||||
CHECK(back.params.play.filter.velAmount == -0.75);
|
CHECK(back.params.play.filter.velAmount == -0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The bake Hold tail (payload v14) -----------------------------------------
|
||||||
|
|
||||||
|
// Hold survives a save/reload as its {rung, modifier} pair, and it is the ONLY field the v14
|
||||||
|
// bump touches — everything either side of it in the record comes back untouched.
|
||||||
|
static void testBakeHoldRoundTripsAndDisturbsNothingElse() {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
in.params.rootOverride = 33;
|
||||||
|
in.params.keyTrack = 0.25;
|
||||||
|
in.params.loopCrossfadeFrames = 64;
|
||||||
|
in.params.play.adsr.releaseSeconds = 0.31;
|
||||||
|
in.params.play.ampSpline.mode = EnvMode::Spline;
|
||||||
|
// A triplet on a rung well away from the default, so neither field can be read off the
|
||||||
|
// other's default and still pass.
|
||||||
|
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
|
||||||
|
|
||||||
|
const ComponentState out =
|
||||||
|
deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||||
|
CHECK(out.params.bakeHold == note::makeDivision(-2, note::DivisionModifier::Triplet));
|
||||||
|
CHECK(out.params.bakeHold != InstrumentParams{}.bakeHold);
|
||||||
|
CHECK(out.selectionId == "pad");
|
||||||
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 33);
|
||||||
|
CHECK(out.params.keyTrack == 0.25);
|
||||||
|
CHECK(out.params.loopCrossfadeFrames == 64);
|
||||||
|
CHECK(out.params.play.adsr.releaseSeconds == 0.31);
|
||||||
|
CHECK(out.params.play.ampSpline.mode == EnvMode::Spline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A v13 blob is a strict PREFIX of v14, so it must lift to the one-bar Hold default with
|
||||||
|
// every other field intact — the reason a project saved before Hold existed reopens the same.
|
||||||
|
static void testV13BlobLiftsToTheDefaultHold() {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
in.params.rootOverride = 55;
|
||||||
|
in.params.play.adsr.decaySeconds = 0.09;
|
||||||
|
in.params.play.filter.enabled = true;
|
||||||
|
in.params.loopCrossfadeFrames = 128;
|
||||||
|
in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted);
|
||||||
|
|
||||||
|
// Stamp the payload back to v13 and drop exactly the v14 tail: byte-for-byte what the
|
||||||
|
// previous binary would have written.
|
||||||
|
const std::vector<std::uint8_t> v13 =
|
||||||
|
payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes);
|
||||||
|
const ComponentState out = deserializeComponentState(v13, 48000.0);
|
||||||
|
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||||
|
CHECK(out.selectionId == "pad");
|
||||||
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
|
||||||
|
CHECK(out.params.play.adsr.decaySeconds == 0.09);
|
||||||
|
CHECK(out.params.play.filter.enabled);
|
||||||
|
CHECK(out.params.loopCrossfadeFrames == 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A corrupt rung/modifier pair clamps to the nearest legal division rather than being held as
|
||||||
|
// an unrepresentable one — makeDivision is the only door, and the codec goes through it.
|
||||||
|
static void testBakeHoldCorruptPairClampsToTheLadder() {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||||
|
CHECK(bytes.size() >= kBakeHoldTailBytes);
|
||||||
|
bytes.resize(bytes.size() - kBakeHoldTailBytes);
|
||||||
|
legacy::u32v(bytes, static_cast<std::uint32_t>(static_cast<std::int32_t>(9999)));
|
||||||
|
legacy::u8v(bytes, 200); // an unnamed modifier byte
|
||||||
|
|
||||||
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
|
CHECK(out.params.bakeHold ==
|
||||||
|
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blob truncated INSIDE the v14 tail costs the Hold alone. It sits last, so without the
|
||||||
|
// revive a stray missing byte would reset every parameter ahead of it to defaults.
|
||||||
|
static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
in.params.rootOverride = 71;
|
||||||
|
in.params.play.adsr.attackSeconds = 0.017;
|
||||||
|
in.params.loopCrossfadeFrames = 96;
|
||||||
|
in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||||
|
CHECK(bytes.size() >= kBakeHoldTailBytes);
|
||||||
|
bytes.resize(bytes.size() - kBakeHoldTailBytes);
|
||||||
|
legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing
|
||||||
|
legacy::u8v(bytes, 0x00);
|
||||||
|
|
||||||
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
|
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||||
|
CHECK(out.selectionId == "pad");
|
||||||
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 71);
|
||||||
|
CHECK(out.params.play.adsr.attackSeconds == 0.017);
|
||||||
|
CHECK(out.params.loopCrossfadeFrames == 96);
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -1632,11 +1747,11 @@ static void testSampleRefsTruncatedMidEntry() {
|
|||||||
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
|
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
|
||||||
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
|
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
|
||||||
// 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
|
// 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
|
||||||
// three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7,
|
// three 6-byte hard-flag tails + the 5-byte v14 bake-Hold tail) = 628 bytes; entry two is
|
||||||
// root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's
|
// 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 648 keeps
|
||||||
// 47 — mid loop.start (offset 23..31).
|
// the first 27 of entry two's 47 — mid loop.start (offset 23..31).
|
||||||
CHECK(bytes.size() > 643);
|
CHECK(bytes.size() > 648);
|
||||||
bytes.resize(bytes.size() - 643);
|
bytes.resize(bytes.size() - 648);
|
||||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||||
CHECK(back.sampleRefs.size() == 1);
|
CHECK(back.sampleRefs.size() == 1);
|
||||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||||
@@ -1748,6 +1863,10 @@ int main() {
|
|||||||
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
|
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
|
||||||
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
|
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
|
||||||
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
|
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
|
||||||
|
testBakeHoldRoundTripsAndDisturbsNothingElse();
|
||||||
|
testV13BlobLiftsToTheDefaultHold();
|
||||||
|
testBakeHoldCorruptPairClampsToTheLadder();
|
||||||
|
testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
|
||||||
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;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// Covers: the beat length of all 39 divisions against a literal rung table (NOT the module's
|
// Covers: the beat length of all 39 divisions against a literal rung table (NOT the module's
|
||||||
// own exponent formula); the 1/64 and 64/1 extremes; the four named example divisions; the
|
// own exponent formula); the 1/64 and 64/1 extremes; the four named example divisions; the
|
||||||
// label notation; picker order and index round-trip; off-ladder clamping of BOTH persisted
|
// label notation; picker order and index round-trip; off-ladder clamping of BOTH persisted
|
||||||
// fields, measured through the readers rather than by comparing two clamped values.
|
// fields, measured through the readers rather than by comparing two clamped values; and
|
||||||
|
// shortestDivisionAtLeast's never-short contract across the rung boundaries.
|
||||||
|
|
||||||
#include "../src/core/instrument/note/musical_division.h"
|
#include "../src/core/instrument/note/musical_division.h"
|
||||||
|
|
||||||
@@ -203,7 +204,49 @@ static void testOutOfRangeIndexClampsIntoTheSet() {
|
|||||||
CHECK(divisionAt(kDivisionCount) == divisionAt(kDivisionCount - 1));
|
CHECK(divisionAt(kDivisionCount) == divisionAt(kDivisionCount - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- shortestDivisionAtLeast --------------------------------------------------
|
||||||
|
|
||||||
|
// The contract is never-short: whatever comes back is at least the requested length, and
|
||||||
|
// nothing shorter on the ladder also is. Swept over every rung and over the gaps between them.
|
||||||
|
static void testShortestAtLeastIsNeverShortAndNeverLonger() {
|
||||||
|
for (int i = 0; i < kDivisionCount; ++i) {
|
||||||
|
const double target = divisionBeats(divisionAt(i));
|
||||||
|
for (const double want : {target, target * 0.99, target * 0.5}) {
|
||||||
|
const Division got = shortestDivisionAtLeast(want);
|
||||||
|
CHECK(divisionBeats(got) >= want - 1e-12);
|
||||||
|
// Nothing on the ladder is both long enough and shorter than the answer.
|
||||||
|
for (int j = 0; j < kDivisionCount; ++j) {
|
||||||
|
const double other = divisionBeats(divisionAt(j));
|
||||||
|
CHECK(!(other >= want && other < divisionBeats(got) - 1e-12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picker order is NOT length order — a rung's triplet is shorter than the previous rung's
|
||||||
|
// dotted — so the answer is not simply "the next index up".
|
||||||
|
static void testShortestAtLeastCrossesRungBoundaries() {
|
||||||
|
// 5 beats: the 2/1 triplet (8 * 2/3 == 5.33) beats the dotted half (6) above it.
|
||||||
|
CHECK(shortestDivisionAtLeast(5.0) == makeDivision(3, DivisionModifier::Triplet));
|
||||||
|
// An exact rung length answers with that rung, not the one above it.
|
||||||
|
CHECK(shortestDivisionAtLeast(4.0) == makeDivision(2, DivisionModifier::Straight));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degenerate inputs land on an end rather than anywhere in the middle.
|
||||||
|
static void testShortestAtLeastDegenerateInputs() {
|
||||||
|
CHECK(shortestDivisionAtLeast(0.0) == divisionAt(0));
|
||||||
|
CHECK(shortestDivisionAtLeast(-10.0) == divisionAt(0));
|
||||||
|
// Past the longest programmable note (the dotted top rung), the top rung is the answer.
|
||||||
|
const Division longest = makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted);
|
||||||
|
CHECK(almostEqual(divisionBeats(longest), kMaxDivisionBeats));
|
||||||
|
CHECK(shortestDivisionAtLeast(kMaxDivisionBeats * 2.0) == longest);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
testShortestAtLeastIsNeverShortAndNeverLonger();
|
||||||
|
testShortestAtLeastCrossesRungBoundaries();
|
||||||
|
testShortestAtLeastDegenerateInputs();
|
||||||
|
|
||||||
testLadderSpansSixtyfourthToSixtyFourWhole();
|
testLadderSpansSixtyfourthToSixtyFourWhole();
|
||||||
testEveryStraightRungHasItsWrittenBeatLength();
|
testEveryStraightRungHasItsWrittenBeatLength();
|
||||||
testDottedIsHalfAgainAndTripletIsTwoThirds();
|
testDottedIsHalfAgainAndTripletIsTwoThirds();
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
// test framework.
|
// test framework.
|
||||||
//
|
//
|
||||||
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
|
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
|
||||||
// toolbar's fixed right-anchored run in order (preview, velocity cell, Mono|Stereo,
|
// toolbar's fixed right-anchored run in order (Hold, bake, preview, velocity cell,
|
||||||
// Browse) with the title taking the remainder; the velocity knob centred in its
|
// Mono|Stereo, Browse) with the title taking the remainder; the velocity and Hold knobs
|
||||||
// cell above its label; the piano strip owning its whole row at every width; no rect on the
|
// centred in their cells above their labels; the piano strip owning its whole row at every
|
||||||
|
// width; no rect on the
|
||||||
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
|
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
|
||||||
// button's play-triangle glyph, which sits inside the button without changing its rect.
|
// button's play-triangle glyph, which sits inside the button without changing its rect.
|
||||||
|
|
||||||
@@ -63,12 +64,13 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
|||||||
CHECK(r.bake.width == kBakeButtonWidth);
|
CHECK(r.bake.width == kBakeButtonWidth);
|
||||||
CHECK(r.bake.y == r.preview.y); // shares the run's button baseline
|
CHECK(r.bake.y == r.preview.y); // shares the run's button baseline
|
||||||
CHECK(r.bake.height == r.preview.height);
|
CHECK(r.bake.height == r.preview.height);
|
||||||
CHECK(r.title.right() <= r.bake.x); // the title yields to the bake, not the preview
|
CHECK(r.holdCell.right() <= r.bake.x);
|
||||||
|
CHECK(r.title.right() <= r.holdCell.x); // the title yields to the whole run
|
||||||
CHECK(r.title.x == band.x + kPad);
|
CHECK(r.title.x == band.x + kPad);
|
||||||
CHECK(r.title.width > 0);
|
CHECK(r.title.width > 0);
|
||||||
|
|
||||||
// Every toolbar rect sits inside the toolbar row.
|
// Every toolbar rect sits inside the toolbar row.
|
||||||
const Rect items[] = {r.title, r.bake, r.preview, r.velCell, r.chanMono,
|
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
|
||||||
r.chanStereo, r.navBrowse};
|
r.chanStereo, r.navBrowse};
|
||||||
for (const Rect& it : items) {
|
for (const Rect& it : items) {
|
||||||
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
||||||
@@ -82,16 +84,17 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
|
|||||||
// stay inside its own row, clear of every control.
|
// stay inside its own row, clear of every control.
|
||||||
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
||||||
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
||||||
const Rect items[] = {r.bake, r.preview, r.velCell, r.chanMono, r.chanStereo,
|
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
|
||||||
r.navBrowse};
|
r.chanStereo, r.navBrowse};
|
||||||
for (const Rect& it : items) {
|
for (const Rect& it : items) {
|
||||||
CHECK(!overlaps(it, r.rootStrip));
|
CHECK(!overlaps(it, r.rootStrip));
|
||||||
CHECK(!overlaps(it, r.title));
|
CHECK(!overlaps(it, r.title));
|
||||||
}
|
}
|
||||||
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
|
// The run's own members are pairwise disjoint (the knob/label pairs are inside their
|
||||||
// so they are checked against the cell's neighbours, not the cell).
|
// cells, so they are checked against the cell's neighbours, not the cell).
|
||||||
for (int i = 0; i < 5; ++i) {
|
constexpr int kRunCount = static_cast<int>(sizeof(items) / sizeof(items[0]));
|
||||||
for (int j = i + 1; j < 5; ++j) CHECK(!overlaps(items[i], items[j]));
|
for (int i = 0; i < kRunCount; ++i) {
|
||||||
|
for (int j = i + 1; j < kRunCount; ++j) CHECK(!overlaps(items[i], items[j]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,25 @@ static void testVelocityKnobIsCentredInItsCellAboveTheLabel() {
|
|||||||
CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right());
|
CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Hold cell reserves its width whether or not the control is drawn — a run laid out
|
||||||
|
// conditionally would slide Bake and Preview out from under the pointer whenever a loop is
|
||||||
|
// dialled in or out. Its interior follows the velocity cell's grammar exactly.
|
||||||
|
static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() {
|
||||||
|
const ChromeRects r = chromeRects(chromeBand(), kKnob);
|
||||||
|
CHECK(r.holdCell.width == r.velCell.width);
|
||||||
|
CHECK(r.holdCell.y == r.velCell.y && r.holdCell.height == r.velCell.height);
|
||||||
|
CHECK(r.holdKnob.width == kKnob && r.holdKnob.height == kKnob);
|
||||||
|
CHECK(r.holdKnob.y == r.holdCell.y);
|
||||||
|
CHECK(r.holdKnob.x - r.holdCell.x == r.holdCell.right() - r.holdKnob.right());
|
||||||
|
CHECK(r.holdLabel.y == r.holdKnob.bottom());
|
||||||
|
CHECK(r.holdLabel.bottom() == r.holdCell.bottom());
|
||||||
|
CHECK(r.holdLabel.x == r.holdCell.x && r.holdLabel.right() == r.holdCell.right());
|
||||||
|
|
||||||
|
// Widening the window leaves the reservation alone; the title takes the extra pixels.
|
||||||
|
const ChromeRects wide = chromeRects(chromeBand(1000, 620), kKnob);
|
||||||
|
CHECK(wide.holdCell.width == r.holdCell.width);
|
||||||
|
}
|
||||||
|
|
||||||
static void testDegenerateBandYieldsNoInvertedRects() {
|
static void testDegenerateBandYieldsNoInvertedRects() {
|
||||||
const ChromeRects empty = chromeRects(Rect{}, kKnob);
|
const ChromeRects empty = chromeRects(Rect{}, kKnob);
|
||||||
CHECK(empty.toolbar.empty() && empty.controls.empty());
|
CHECK(empty.toolbar.empty() && empty.controls.empty());
|
||||||
@@ -133,7 +155,8 @@ static void testDegenerateBandYieldsNoInvertedRects() {
|
|||||||
// A band far too narrow for the fixed run: everything collapses left, nothing inverts.
|
// A band far too narrow for the fixed run: everything collapses left, nothing inverts.
|
||||||
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
|
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
|
||||||
kKnob);
|
kKnob);
|
||||||
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel,
|
||||||
|
tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
||||||
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
|
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
|
||||||
tiny.rootStrip};
|
tiny.rootStrip};
|
||||||
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
||||||
@@ -182,6 +205,7 @@ int main() {
|
|||||||
testChromePartsNeverOverlapAtAnyWidth();
|
testChromePartsNeverOverlapAtAnyWidth();
|
||||||
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
|
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
|
||||||
testVelocityKnobIsCentredInItsCellAboveTheLabel();
|
testVelocityKnobIsCentredInItsCellAboveTheLabel();
|
||||||
|
testHoldCellIsReservedAndFollowsTheVelocityCellGrammar();
|
||||||
testDegenerateBandYieldsNoInvertedRects();
|
testDegenerateBandYieldsNoInvertedRects();
|
||||||
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
|
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
|
||||||
testPreviewGlyphDegradesRatherThanOverflowing();
|
testPreviewGlyphDegradesRatherThanOverflowing();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Covers triggerPlayLength: zero play length, startFrame set, startFrame past frameCount,
|
// Covers triggerPlayLength: zero play length, startFrame set, startFrame past frameCount,
|
||||||
// rounding, and the Finding 1 regression (start-point set — the case that was broken before
|
// rounding, and the Finding 1 regression (start-point set — the case that was broken before
|
||||||
// this module existed).
|
// this module existed); plus effectiveLengthFraction, the spline fold every consumer of the
|
||||||
|
// span must go through.
|
||||||
|
|
||||||
#include "../src/core/instrument/map/trigger_seam.h"
|
#include "../src/core/instrument/map/trigger_seam.h"
|
||||||
|
|
||||||
@@ -57,7 +58,46 @@ static void testPlayLengthRounding() {
|
|||||||
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
|
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- effectiveLengthFraction --------------------------------------------------
|
||||||
|
|
||||||
|
// A drawn contour covers the FULL sample length, so the stored %-knob goes inert. It is not
|
||||||
|
// cleared, though — a pre-spline value survives in the record, and every reader that takes it
|
||||||
|
// raw plays, draws or bakes a fraction of the take.
|
||||||
|
static void testDrawnEnvelopeFoldsTheFractionToOne() {
|
||||||
|
PlayParams p;
|
||||||
|
p.trigger.lengthFraction = 0.25;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 0.25); // staged: the knob is what it says
|
||||||
|
|
||||||
|
p.ampSpline.mode = EnvMode::Spline;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 1.0); // drawn: the whole take
|
||||||
|
CHECK(p.trigger.lengthFraction == 0.25); // …and the stored value is untouched
|
||||||
|
|
||||||
|
// Flipping back restores the stored fraction, which is why the fold cannot be a write.
|
||||||
|
p.ampSpline.mode = EnvMode::Staged;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fold reads the SAME predicate the engine's Gate refusal does, gating flags included: a
|
||||||
|
// Spline mode on a DISABLED pitch/filter envelope binds no cursor, so it must not fold.
|
||||||
|
static void testDisabledEnvelopesDoNotFoldTheFraction() {
|
||||||
|
PlayParams p;
|
||||||
|
p.trigger.lengthFraction = 0.5;
|
||||||
|
|
||||||
|
p.pitchSpline.mode = EnvMode::Spline;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 0.5); // pitchEnv.enabled is still false
|
||||||
|
p.pitchEnv.enabled = true;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 1.0);
|
||||||
|
|
||||||
|
p.pitchEnv.enabled = false;
|
||||||
|
p.filterSpline.mode = EnvMode::Spline;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 0.5); // filter.enabled is still false
|
||||||
|
p.filter.enabled = true;
|
||||||
|
CHECK(effectiveLengthFraction(p) == 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
testDrawnEnvelopeFoldsTheFractionToOne();
|
||||||
|
testDisabledEnvelopesDoNotFoldTheFraction();
|
||||||
testPlayLengthNoStartPoint();
|
testPlayLengthNoStartPoint();
|
||||||
testPlayLengthWithStartPoint();
|
testPlayLengthWithStartPoint();
|
||||||
testPlayLengthZeroFrameCount();
|
testPlayLengthZeroFrameCount();
|
||||||
|
|||||||
Reference in New Issue
Block a user