Merge dev into phase-psi: take Ξ-W3 before Ψ lands
# Conflicts: # docs/COMPLETED.md # docs/TODO.md
This commit is contained in:
@@ -12,8 +12,8 @@ subdirectories:
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
|
||||
play-span formula).
|
||||
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
|
||||
resolution, and anchored start/end offsets — the one record and resolver a
|
||||
capture-signal popup and the offline bake read from, so they cannot diverge.
|
||||
resolution, and anchored start/end offsets — the one record and resolver the offline bake
|
||||
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
|
||||
window, the offline render over a voice engine built for that render alone, and the
|
||||
ratified post-bake reset. See `bake/CLAUDE.md`.
|
||||
@@ -281,7 +281,7 @@ anything for a trigger shape.
|
||||
### `engine/`
|
||||
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
|
||||
@@ -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`.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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 a %-length becomes the source-frame span the overlay draws over and the bake's window holds, threading `startFrame` correctly and clamping the fraction the same way `Voice::start` does (the engine evaluates the same formula inline rather than depending on `map/`). The spline fold every consumer must apply first — `effectiveLengthFraction` — is `play_params.h`'s, beside the rest of that rule family. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
||||
|
||||
### `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.
|
||||
- `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 only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). 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, ordered by LENGTH rather than by the ladder's presentation order. 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.
|
||||
- `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.
|
||||
|
||||
@@ -23,10 +23,25 @@ decision about what the render made obsolete.
|
||||
even a pathological envelope cannot run past the window.
|
||||
- **The whole signal chain is printed, master gain included** — the gain multiply in
|
||||
`bake_render.cpp` carries the argument for why.
|
||||
- **A degenerate or unholdable window is refused, not rendered.** `planBake` returns nullopt
|
||||
for a collapsed window, a non-positive rate, a window that rounds to no frames, and one
|
||||
past `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.
|
||||
- **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a
|
||||
collapsed window, a non-positive rate, a window that rounds to no frames, and one past
|
||||
`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. 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 errs outward — the
|
||||
Varispeed bound takes the deepest reachable offset the voice can play, 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. What it does NOT mean is quantizing: a derived length is an exact duration and a
|
||||
finite ladder cannot express one (`note/CLAUDE.md`) — rounding up to a rung truncated any
|
||||
source past the top rung, which is the failure this asymmetry exists to prevent.
|
||||
- **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.
|
||||
A parameter added later therefore resets by default — the safe direction, since
|
||||
@@ -38,10 +53,10 @@ decision about what the render made obsolete.
|
||||
|
||||
## Modules
|
||||
|
||||
- `bake_plan` — `defaultBakeProgram` (the program a bake uses until the capture-signal popup
|
||||
ships; its end offset is DERIVED from the dialed sound, never constant), `BakePlan` (the
|
||||
- `bake_plan` — `defaultBakeProgram` (the whole programmed note, DERIVED from the dialed
|
||||
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
|
||||
`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
|
||||
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
|
||||
@@ -52,10 +67,12 @@ decision about what the render made obsolete.
|
||||
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
|
||||
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.
|
||||
- **`defaultBakeProgram`'s Trigger window bounds the Varispeed read stretch, it does not
|
||||
model it.** A downward pitch offset makes the read head take longer to cross the play
|
||||
span, so the window is scaled by the deepest downward offset the voice can reach — an
|
||||
upper bound, so a shallower excursion leaves trailing silence in the file. The
|
||||
capture-signal popup is where a user sets the window exactly.
|
||||
- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch
|
||||
offset makes the read head take longer to cross its span, so the window is scaled by the
|
||||
deepest downward offset the voice can reach — a shallower excursion leaves trailing silence
|
||||
in the file. Both the Trigger span and the Gate exhaustion length take it.
|
||||
- **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
|
||||
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
|
||||
# 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
|
||||
SOURCES bake_plan.cpp
|
||||
LINK PUBLIC note_program sampler_core trigger_seam)
|
||||
@@ -10,6 +11,10 @@ reasampler_pure_library(bake_render
|
||||
LINK PUBLIC bake_plan sampler_core)
|
||||
reasampler_test(bake_render LINK bake_render)
|
||||
|
||||
# No library of its own: the derived window is a PROPERTY of bake_plan + bake_render
|
||||
# together, and this measures it end to end rather than either half in isolation.
|
||||
reasampler_test(bake_window LINK bake_plan bake_render)
|
||||
|
||||
# sample_map carries InstrumentParams, which is the whole of what a reset rewrites.
|
||||
reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map)
|
||||
reasampler_test(bake_reset LINK bake_reset)
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
#include <algorithm>
|
||||
#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" // triggerPlayLength (the one span formula)
|
||||
|
||||
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
|
||||
// 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
|
||||
// Gate release is ticked per output frame, so neither is affected.
|
||||
// stretches how long the source takes to play out. Preserve decouples the two, and a Gate
|
||||
// release is ticked per output frame, so neither is affected.
|
||||
double downwardSemitones(const PlayParams& play, int velocity) {
|
||||
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
|
||||
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
|
||||
@@ -44,39 +46,78 @@ double downwardSemitones(const PlayParams& play, int velocity) {
|
||||
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
|
||||
|
||||
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,
|
||||
note::Tempo tempo) {
|
||||
NoteProgram p; // 1/4 straight, velocity 100, capture opening at note-on
|
||||
note::Division hold, note::Velocity velocity) {
|
||||
NoteProgram p; // a quarter note, capture opening at note-on
|
||||
p.velocity = velocity;
|
||||
if (renderSampleRate <= 0) return p;
|
||||
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;
|
||||
if (dialed.play.playMode == PlayMode::Trigger) {
|
||||
// Trigger ignores note-off entirely: the sound ends when the read head reaches the
|
||||
// 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.
|
||||
const std::int64_t span = map::triggerPlayLength(
|
||||
dialed.play.trigger.lengthFraction,
|
||||
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 -
|
||||
tempo.beatsToSeconds(note::divisionBeats(p.length));
|
||||
// play span's end. The note is that span, so the window closes on the sound rather
|
||||
// than on a length the voice never consulted.
|
||||
const std::int64_t span =
|
||||
map::triggerPlayLength(effectiveLengthFraction(dialed.play), frameCount, start);
|
||||
p.length = note::lengthOfSeconds(static_cast<double>(span) / rate * stretch);
|
||||
} 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 = note::lengthOfDivision(hold);
|
||||
endOffsetSeconds = releaseSeconds;
|
||||
} else {
|
||||
// Gate: the release is the one stage that runs after note-off, so it is exactly
|
||||
// what the window has to hold past it.
|
||||
endOffsetSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
|
||||
// Gate with no loop: the read head runs off the source and frees the voice whether or
|
||||
// not the gate is still down, so the maximal sound is the whole post-start span held.
|
||||
// Exact, not a ladder rung: a source longer than the ladder's top rung would otherwise
|
||||
// take that rung and release mid-sound, and rounding up to one costs trailing silence
|
||||
// on every other source.
|
||||
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - start);
|
||||
p.length = note::lengthOfSeconds(static_cast<double>(postStart) / rate * stretch);
|
||||
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));
|
||||
return p;
|
||||
}
|
||||
|
||||
std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
|
||||
int rootNote) {
|
||||
if (resolved.windowCollapsed) return std::nullopt;
|
||||
if (sampleRate <= 0) return std::nullopt;
|
||||
PlannedBake planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) {
|
||||
const PlannedBake empty{std::nullopt, BakeRefusal::EmptyWindow};
|
||||
const PlannedBake tooLong{std::nullopt, BakeRefusal::PastFrameCeiling};
|
||||
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
|
||||
// start offset is legal and means the capture opens after the note — so the head is
|
||||
@@ -86,22 +127,22 @@ std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
|
||||
BakePlan plan;
|
||||
plan.sampleRate = sampleRate;
|
||||
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
|
||||
return std::nullopt;
|
||||
if (plan.totalFrames <= 0) return std::nullopt;
|
||||
return tooLong;
|
||||
if (plan.totalFrames <= 0) return empty;
|
||||
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
|
||||
plan.leadInFrames))
|
||||
return std::nullopt;
|
||||
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return std::nullopt;
|
||||
return tooLong;
|
||||
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return tooLong;
|
||||
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
|
||||
plan.noteOffFrame))
|
||||
return std::nullopt;
|
||||
return tooLong;
|
||||
// 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.note = std::clamp(rootNote, 0, 127);
|
||||
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
|
||||
return plan;
|
||||
return PlannedBake{plan, BakeRefusal::None};
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
|
||||
@@ -20,14 +20,36 @@ namespace reasampler::instrument::bake {
|
||||
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
|
||||
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
|
||||
// default velocity, opening at note-on, with the END offset derived from `dialed` — Gate's
|
||||
// release, or Trigger's play span, at `renderSampleRate` (the rate the bake will render at,
|
||||
// which is what the engine's frame counts are actually consumed against). Derived rather
|
||||
// than constant because the release knob alone spans two seconds, so any fixed tail cuts a
|
||||
// long decay mid-flight.
|
||||
// Whether the window needs a user-supplied hold. A Gate voice over an ACTIVE sustain loop
|
||||
// sounds for as long as it is held, by definition — there is no intrinsic end to derive, and
|
||||
// this is the ONLY case in which there isn't. Answered by the engine's own loop fold, so the
|
||||
// control that collects the hold cannot appear for a loop the voice would refuse.
|
||||
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
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 — the note IS the play span (note-off is ignored anyway), 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 the whole post-start span, stretched the same way.
|
||||
//
|
||||
// Both derived lengths are EXACT durations, not ladder rungs: a source longer than the
|
||||
// ladder's top rung has no rung that covers it, and quantizing up to one overshoots every
|
||||
// other source (see note/CLAUDE.md). `hold` alone stays musical — it is a picker.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` —
|
||||
// meets the tempo in resolveNote, with the rest of the program's beat-denominated fields.
|
||||
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
||||
note::Tempo tempo);
|
||||
note::Division hold, note::Velocity velocity);
|
||||
|
||||
// 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
|
||||
@@ -49,10 +71,23 @@ struct BakePlan {
|
||||
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
|
||||
};
|
||||
|
||||
// nullopt for a collapsed window, a non-positive rate, a window that rounds to no frames,
|
||||
// or one past kMaxBakeFrames — a buffer that is degenerate or unholdable is refused rather
|
||||
// than rendered. `rootNote` and the resolved velocity are clamped into MIDI range.
|
||||
std::optional<BakePlan> planBake(const note::ResolvedNote& resolved, int sampleRate,
|
||||
int rootNote);
|
||||
// Why a window was refused. The two are different user problems and read as different
|
||||
// sentences: an empty window is a programming mistake, a window past the ceiling is a legal
|
||||
// dialed sound that simply cannot be held in one pass.
|
||||
enum class BakeRefusal : std::uint8_t {
|
||||
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
|
||||
|
||||
@@ -195,13 +195,29 @@ bool splineActive(const Play& p) {
|
||||
(p.filter.enabled && p.filterSpline.mode == EnvMode::Spline);
|
||||
}
|
||||
|
||||
// The one enforcement of splineActive's rule (see its doc above). Header-inline and
|
||||
// allocation-free: play_params.h sits on the per-voice-per-sample include path. Both callers —
|
||||
// resolvePlay (sample_map.cpp) and the editor's applyControl — route through here, so the two
|
||||
// cannot drift apart.
|
||||
// The mode the engine will actually run, and the one home of splineActive's rule (see its doc
|
||||
// above). Header-inline and allocation-free: play_params.h sits on the per-voice-per-sample
|
||||
// include path. Every caller — resolvePlay (sample_map.cpp), the editor's applyControl, and
|
||||
// the editor's read-only predicates — routes through one of these two, so none of them can
|
||||
// drift into a second reading of the fields.
|
||||
template <class Play>
|
||||
PlayMode effectivePlayMode(const Play& p) {
|
||||
return splineActive(p) ? PlayMode::Trigger : p.playMode;
|
||||
}
|
||||
|
||||
template <class Play>
|
||||
void enforceGateUnavailableWhileDrawn(Play& p) {
|
||||
if (splineActive(p)) p.playMode = PlayMode::Trigger;
|
||||
p.playMode = effectivePlayMode(p);
|
||||
}
|
||||
|
||||
// The %-length the voice ACTUALLY plays. Same rule family, same reason it is templated: a drawn
|
||||
// contour is a pure time function over the full sample length, so any active spline EG folds
|
||||
// the fraction to 1.0 while the stored knob goes inert — but the stored value survives, so a
|
||||
// pre-spline setting is still there to be read. Every consumer of the Trigger span must fold it
|
||||
// here or it silently plays/draws/bakes a fraction of the take.
|
||||
template <class Play>
|
||||
double effectiveLengthFraction(const Play& p) {
|
||||
return splineActive(p) ? 1.0 : p.trigger.lengthFraction;
|
||||
}
|
||||
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
|
||||
@@ -101,14 +101,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)) — except kTrigLength is INERT
|
||||
// while any spline EG is active (splineActive, play_params.h): a contour is a pure
|
||||
// function over the FULL sample length, so truncating playEnd_ to a %-length would
|
||||
// hard-cut it mid-shape. The staged Trigger AHD below (trigSpan) plays the same full
|
||||
// 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)
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
|
||||
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
|
||||
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
|
||||
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
|
||||
// the whole take.
|
||||
double frac = effectiveLengthFraction(p);
|
||||
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(postStart) * frac + 0.5); // round
|
||||
|
||||
@@ -81,6 +81,17 @@ inline double filterNormPerOctave() {
|
||||
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)
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
@@ -2,8 +2,8 @@ reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
|
||||
reasampler_test(bridge_marshal LINK bridge_marshal)
|
||||
|
||||
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
|
||||
# Links only trigger_seam — not even editor_geometry — the plainest data-boundary proof
|
||||
# available.
|
||||
# Plain frame arithmetic over doubles: no engine, no value layer, no editor geometry. The
|
||||
# %-length fold it used to host lives with its siblings in play_params.h.
|
||||
reasampler_test(trigger_seam LINK trigger_seam)
|
||||
|
||||
reasampler_pure_library(bank_sync
|
||||
@@ -21,7 +21,7 @@ reasampler_test(bank_sync LINK bank_sync)
|
||||
# the format already keeps on independent version axes (see component_state_io.h).
|
||||
reasampler_pure_library(component_state_io
|
||||
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
|
||||
# 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)
|
||||
@@ -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.
|
||||
reasampler_pure_library(sample_map
|
||||
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
|
||||
# both halves of the mapping/codec split where the frozen-format assertions live.
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
#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}
|
||||
// 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
|
||||
// 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
|
||||
@@ -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
|
||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||
// a reader detects record shape independent of the envelope version.
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 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;
|
||||
|
||||
// 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.
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -94,24 +94,41 @@ void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, dou
|
||||
out.decayCurve = kTriggerFadeLiftDecayCurve;
|
||||
}
|
||||
|
||||
// A wire double whose consumers assume a domain they cannot check: the seconds fields reach
|
||||
// resolvePlay's static_cast<std::int64_t> and peakSemitones reaches the bake's pow() and the
|
||||
// voice's ratio multiply — both undefined or poisoning on NaN. Degrades to the field's own
|
||||
// construction default, so a damaged blob loses that field rather than the record.
|
||||
double finiteOr(double v, double fallback) { return std::isfinite(v) ? v : fallback; }
|
||||
|
||||
// A root-note override off the wire. Clamped HERE and not only where it is consumed: planBake
|
||||
// clamps the note it renders at into MIDI range while the SampleData keeps the raw override as
|
||||
// its root, and the two disagreeing makes the read rate something other than 1 — which
|
||||
// mis-sizes the bake's window in the truncating direction.
|
||||
int clampMidiNote(int note) { return (std::max)(0, (std::min)(127, note)); }
|
||||
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate) {
|
||||
const PlaySeconds fallback; // the construction defaults, read rather than restated
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.adsr.holdSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.holdSeconds);
|
||||
p.play.trigger.lengthFraction =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.trigger.lengthFraction);
|
||||
const std::int64_t fadeIn = r.i64();
|
||||
const std::int64_t fadeOut = r.i64();
|
||||
liftTriggerFades(fadeIn, fadeOut, projectRate, p.play.trigAhd);
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.shape.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.shape.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.shape.attackSeconds =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.shape.attackSeconds);
|
||||
p.play.pitchEnv.shape.decaySeconds =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.shape.decaySeconds);
|
||||
p.play.pitchEnv.peakSemitones =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.peakSemitones);
|
||||
p.play.adsr.attackSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.attackSeconds);
|
||||
p.play.adsr.decaySeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.decaySeconds);
|
||||
p.play.adsr.sustainLevel = finiteOr(bitsToDouble(r.u64()), fallback.adsr.sustainLevel);
|
||||
p.play.adsr.releaseSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.releaseSeconds);
|
||||
}
|
||||
|
||||
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain
|
||||
@@ -170,6 +187,14 @@ void readSplineEnv(ByteReader& r, SplineEnv& s) {
|
||||
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
}
|
||||
|
||||
// A block whose declared length overruns what the blob still holds has no realignment point:
|
||||
// every byte after it belongs to a block that was truncated, so a later tail that reads them
|
||||
// as its own gets an ARBITRARY value — and a tail that clamps (the bake Hold does) turns that
|
||||
// into a legal-looking fabrication rather than an obvious one. Draining is what makes the
|
||||
// stream's end honest: each later tail then reads past it and degrades to absent through its
|
||||
// own revive, while the record that parsed cleanly ahead of the damage survives.
|
||||
void drainUnaligned(ByteReader& r) { r.pos = r.bytes.size(); }
|
||||
|
||||
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
|
||||
// curve fromPoints actually produced — including an out-of-bounds or truncated one — is
|
||||
// dropped rather than applied to shifted knots, and the whole params record parsed ahead of
|
||||
@@ -185,7 +210,10 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) {
|
||||
return;
|
||||
}
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
if (count > remaining) return; // bound-and-skip: cannot safely reserve/read this many
|
||||
if (count > remaining) {
|
||||
drainUnaligned(r); // the flags this count promised are not all there
|
||||
return;
|
||||
}
|
||||
std::vector<std::uint8_t> flags;
|
||||
flags.reserve(count);
|
||||
for (std::uint32_t i = 0; i < count; ++i) flags.push_back(r.u8());
|
||||
@@ -193,11 +221,30 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) {
|
||||
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,
|
||||
// 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
|
||||
// v12 domain re-tag needs no version branch (see component_state_io.h).
|
||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
const FilterSeconds fallback; // the construction defaults, read rather than restated
|
||||
FilterSeconds& f = p.play.filter;
|
||||
f.enabled = (r.u8() != 0);
|
||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
@@ -214,11 +261,11 @@ void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||
f.env.attackSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.attackSeconds);
|
||||
f.env.holdSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.holdSeconds);
|
||||
f.env.decaySeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.decaySeconds);
|
||||
f.env.sustainLevel = finiteOr(bitsToDouble(r.u64()), fallback.env.sustainLevel);
|
||||
f.env.releaseSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.releaseSeconds);
|
||||
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
}
|
||||
|
||||
@@ -288,7 +335,7 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (hasOverride) p.rootOverride = clampMidiNote(r.i32());
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
@@ -307,9 +354,11 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
const PlaySeconds fallback; // same guard as readSecondsPlayTail's peer fields
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.trigger.lengthFraction);
|
||||
const std::int64_t fadeIn = r.i64();
|
||||
const std::int64_t fadeOut = r.i64();
|
||||
liftTriggerFades(fadeIn, fadeOut, liftRate, p.play.trigAhd);
|
||||
@@ -317,13 +366,16 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.shape.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.shape.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.peakSemitones);
|
||||
} else if (secondsPlay) {
|
||||
readSecondsPlayTail(r, p, projectRate);
|
||||
}
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat(). A NaN
|
||||
// reaches keyTrackedRatio -> baseRatio_ -> readPos_'s per-sample cast (voice.h) — the
|
||||
// same guard the v8+ single-record reader applies to its own keyTrack below.
|
||||
if (keyTrackTail) p.keyTrack = finiteOr(bitsToDouble(r.u64()), InstrumentParams{}.keyTrack);
|
||||
if (curveTail) {
|
||||
readCurveTail(r, p.velocityCurve,
|
||||
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
@@ -418,6 +470,11 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
|
||||
putHardFlags(out, p.velocityCurve);
|
||||
putHardFlags(out, f.velocityCurve);
|
||||
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
|
||||
@@ -434,7 +491,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
if (hasRoot) p.rootOverride = clampMidiNote(r.i32());
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
@@ -446,7 +503,10 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p, projectRate);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
// NaN reaches keyTrackedRatio (voice.h) -> baseRatio_ -> readPos_'s per-sample
|
||||
// static_cast<std::int64_t> — UB on the per-sample path. Guarded here, codec-side, so
|
||||
// that path needs no check of its own.
|
||||
p.keyTrack = finiteOr(bitsToDouble(r.u64()), InstrumentParams{}.keyTrack);
|
||||
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
||||
@@ -468,6 +528,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
readHardFlags(r, p.play.filter.velocityCurve);
|
||||
readHardFlags(r, p.play.pitchVelocityCurve);
|
||||
}
|
||||
if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#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/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)
|
||||
|
||||
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
|
||||
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
||||
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)
|
||||
|
||||
@@ -10,8 +10,10 @@ std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame) {
|
||||
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - startFrame);
|
||||
if (postStart <= 0 || lengthFraction <= 0.0) return 0;
|
||||
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
|
||||
if (postStart <= 0 || !(lengthFraction > 0.0)) return 0; // also catches NaN
|
||||
const double frac = (std::min)(1.0, lengthFraction);
|
||||
const auto len = static_cast<std::int64_t>(frac * static_cast<double>(postStart) + 0.5);
|
||||
return (std::min)(postStart, (std::max)(std::int64_t{0}, len));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// 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
|
||||
// note-on resolve and the editor's overlay pack cannot disagree about where a Trigger note
|
||||
// ends.
|
||||
// trigger_seam — the shared Trigger play-span formula: how a %-length becomes the source-frame
|
||||
// span the voice plays, the overlay draws over, and the bake's window holds. One home so those
|
||||
// cannot disagree about where a Trigger note ends.
|
||||
//
|
||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||
// playLengthFrames = round(clamp01(lengthFraction) * (frameCount - startFrame))
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -11,9 +10,14 @@
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
|
||||
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
|
||||
// or lengthFraction <= 0.
|
||||
// `startFrame` is the effective start point (0 when absent), `lengthFraction` the EFFECTIVE one
|
||||
// — fold it through effectiveLengthFraction (play_params.h) first, or a stored-but-inert %-knob
|
||||
// shortens the span. Returns 0 for an empty post-start span and for any fraction that is not
|
||||
// above zero, NaN included; the fraction is clamped to 1.0 and the result to the span, so a
|
||||
// corrupt stored value cannot reach past the source.
|
||||
//
|
||||
// Voice::start evaluates this same clamped formula inline rather than calling it: the engine
|
||||
// does not depend on `map/`, in either direction.
|
||||
std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame);
|
||||
|
||||
@@ -10,7 +10,11 @@ types, no VST3 types, no host at all.
|
||||
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
|
||||
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. Everything else it
|
||||
derives is an exact duration; see the note-length invariant below.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -23,9 +27,17 @@ diverge: the capture-signal popup that edits it and the bake that renders it.
|
||||
directory — `Tempo` has no default and cannot be constructed without one.
|
||||
- **Resolved times are rate-free seconds.** The standing ruling: no sample rate appears
|
||||
here; the caller converts seconds to frames against the live rate.
|
||||
- **Note length is musical-division-only.** Offsets carry the ms/beats duality; the note
|
||||
length does not. A free-duration note length would make two records describe the same
|
||||
performance at one tempo and different performances at another.
|
||||
- **A note length is EITHER a musical division or an exact duration, and which one says who
|
||||
produced it.** *(Amends the earlier "musical-division-only" rule, which was settled when
|
||||
every length was hand-programmed through a picker.)* A PICKED length stays a `Division`: a
|
||||
picker's rungs are the point, and the tempo-relative reading — the same record meaning a
|
||||
different duration at a different tempo — is what the user asked for. A DERIVED length is
|
||||
exact seconds, because the ladder is finite: a source longer than its top rung has no rung
|
||||
that covers it, so quantizing up saturates and releases the note mid-sound, and on every
|
||||
shorter source it buys trailing silence for nothing. `NoteLength` holds one or the other and
|
||||
`noteLengthSeconds` resolves both, so no reader can pick the wrong denomination. Today the
|
||||
bake is the only producer of each: its window derivations are exact, and the Gate-over-a-loop
|
||||
Hold knob is the one picker.
|
||||
- **A division persists as its `{quarterExponent, modifier}` pair, never as its picker
|
||||
index.** The index is presentation order and would silently re-map every saved record if
|
||||
the ladder ever gained a rung or a modifier.
|
||||
@@ -61,9 +73,9 @@ diverge: the capture-signal popup that edits it and the bake that renders it.
|
||||
to. `fromBpm` validates by running the extreme conversions rather than by testing the
|
||||
`60/bpm` reciprocal they start from — that reciprocal stays finite well past the point the
|
||||
multiply after it overflows.
|
||||
- `note_program` — `Velocity` (clamped 1..127), the denominated `OffsetAmount` and its unit
|
||||
toggle, the anchored `StartOffset` / `EndOffset`, the `NoteProgram` record, and
|
||||
`resolveNote`.
|
||||
- `note_program` — `Velocity` (clamped 1..127), the two-denomination `NoteLength` and its
|
||||
resolver, the denominated `OffsetAmount` and its unit toggle, the anchored `StartOffset` /
|
||||
`EndOffset`, the `NoteProgram` record, and `resolveNote`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -31,6 +31,26 @@ OffsetAmount offsetOf(double magnitude, Denomination denomination) {
|
||||
return OffsetAmount(bounded, named ? denomination : Denomination::Milliseconds);
|
||||
}
|
||||
|
||||
NoteLength lengthOfDivision(Division division) { return NoteLength(division, 0.0, false); }
|
||||
|
||||
NoteLength lengthOfSeconds(double seconds) {
|
||||
const double bounded = std::isnan(seconds)
|
||||
? 0.0
|
||||
: (std::max)(0.0, (std::min)(kMaxLengthSeconds, seconds));
|
||||
return NoteLength(Division{}, bounded, true);
|
||||
}
|
||||
|
||||
bool operator==(NoteLength a, NoteLength b) {
|
||||
if (a.exact_ != b.exact_) return false;
|
||||
return a.exact_ ? a.seconds_ == b.seconds_ : a.division_ == b.division_;
|
||||
}
|
||||
|
||||
bool operator!=(NoteLength a, NoteLength b) { return !(a == b); }
|
||||
|
||||
double noteLengthSeconds(NoteLength length, Tempo tempo) {
|
||||
return length.exact_ ? length.seconds_ : tempo.beatsToSeconds(divisionBeats(length.division_));
|
||||
}
|
||||
|
||||
OffsetAmount offsetFromMs(double ms) { return offsetOf(ms, Denomination::Milliseconds); }
|
||||
|
||||
OffsetAmount offsetFromBeats(double beats) { return offsetOf(beats, Denomination::Beats); }
|
||||
@@ -94,7 +114,7 @@ bool operator!=(const NoteProgram& a, const NoteProgram& b) { return !(a == b);
|
||||
|
||||
ResolvedNote resolveNote(const NoteProgram& program, Tempo tempo) {
|
||||
ResolvedNote out;
|
||||
out.noteOffSeconds = tempo.beatsToSeconds(divisionBeats(program.length));
|
||||
out.noteOffSeconds = noteLengthSeconds(program.length, tempo);
|
||||
out.captureStartSeconds = offsetSeconds(program.start.amount(), tempo);
|
||||
const double rawEndSeconds = out.noteOffSeconds + offsetSeconds(program.end.amount(), tempo);
|
||||
// An inverted window has no meaning to a renderer, so a far-negative end offset yields a
|
||||
|
||||
@@ -85,6 +85,47 @@ OffsetAmount redenominate(OffsetAmount amount, Denomination to, Tempo tempo);
|
||||
OffsetAmount withMsView(OffsetAmount amount, double ms, Tempo tempo);
|
||||
OffsetAmount withBeatsView(OffsetAmount amount, double beats, Tempo tempo);
|
||||
|
||||
// The largest exact note length, in seconds — the ms ceiling restated in the unit an exact
|
||||
// length is entered in, so a length and an offset cap at the same instant.
|
||||
inline constexpr double kMaxLengthSeconds = msToSeconds(kMaxConvertibleMagnitude);
|
||||
|
||||
class NoteLength;
|
||||
|
||||
// The two doors. A DERIVED length is exact: `lengthOfSeconds` is what every computed duration
|
||||
// takes, and rounding one onto the ladder is what truncates a source longer than the ladder's
|
||||
// top rung. A PICKED length is a `Division`, because a picker's rungs are the point. Both
|
||||
// clamp: a negative or NaN duration names no length and becomes zero, a magnitude past
|
||||
// kMaxLengthSeconds clamps to it, and `makeDivision` already holds the rung's own domain.
|
||||
NoteLength lengthOfDivision(Division division);
|
||||
NoteLength lengthOfSeconds(double seconds);
|
||||
|
||||
// One length, in whichever denomination its door established. There is no accessor per
|
||||
// denomination: `noteLengthSeconds` resolves both, so no reader can pick the wrong one.
|
||||
class NoteLength {
|
||||
public:
|
||||
NoteLength() = default; // a quarter note — Division's own default
|
||||
|
||||
private:
|
||||
NoteLength(Division division, double seconds, bool exact)
|
||||
: division_(division), seconds_(seconds), exact_(exact) {}
|
||||
friend NoteLength lengthOfDivision(Division division);
|
||||
friend NoteLength lengthOfSeconds(double seconds);
|
||||
friend double noteLengthSeconds(NoteLength length, Tempo tempo);
|
||||
friend bool operator==(NoteLength a, NoteLength b);
|
||||
|
||||
Division division_{};
|
||||
double seconds_ = 0.0;
|
||||
bool exact_ = false;
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<NoteLength, Division>,
|
||||
"lengthOfDivision must be the only way to give a NoteLength a rung");
|
||||
|
||||
bool operator==(NoteLength a, NoteLength b);
|
||||
bool operator!=(NoteLength a, NoteLength b);
|
||||
|
||||
double noteLengthSeconds(NoteLength length, Tempo tempo);
|
||||
|
||||
// Two types rather than one carrying an anchor field: the anchor is then unswappable at
|
||||
// compile time. Sign is uniform — positive is later in time — so a capture that opens before
|
||||
// the note is a negative start offset, and a negative end offset truncates before release.
|
||||
@@ -114,7 +155,7 @@ static_assert(!std::is_convertible_v<OffsetAmount, StartOffset>,
|
||||
"the anchor constructor must stay explicit");
|
||||
|
||||
struct NoteProgram {
|
||||
Division length{};
|
||||
NoteLength length{};
|
||||
StartOffset start{};
|
||||
EndOffset end{};
|
||||
Velocity velocity{};
|
||||
|
||||
@@ -79,6 +79,11 @@ reasampler_pure_library(deck_values
|
||||
LINK PUBLIC deck_groups play_seconds envelope_overlay)
|
||||
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)
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// bake_hold.cpp — see bake_hold.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/bake_hold.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kLastSlot = note::kDivisionCount - 1;
|
||||
|
||||
using Order = std::array<int, note::kDivisionCount>;
|
||||
|
||||
// Slot -> picker index, sorted by LENGTH. The ladder's own order is presentation order, in
|
||||
// which a rung's triplet is shorter than the previous rung's dotted (musical_division.cpp) —
|
||||
// so addressing it directly makes a knob whose whole meaning is duration shorten the note at
|
||||
// every rung boundary. Built once; every length on the ladder is distinct, so the sort is total.
|
||||
const Order& bySlot() {
|
||||
static const Order order = [] {
|
||||
Order a{};
|
||||
for (int i = 0; i < note::kDivisionCount; ++i) a[static_cast<std::size_t>(i)] = i;
|
||||
std::sort(a.begin(), a.end(), [](int l, int r) {
|
||||
return note::divisionBeats(note::divisionAt(l)) <
|
||||
note::divisionBeats(note::divisionAt(r));
|
||||
});
|
||||
return a;
|
||||
}();
|
||||
return order;
|
||||
}
|
||||
|
||||
// The inverse: picker index -> slot.
|
||||
const Order& toSlot() {
|
||||
static const Order inverse = [] {
|
||||
Order a{};
|
||||
const Order& forward = bySlot();
|
||||
for (int slot = 0; slot < note::kDivisionCount; ++slot)
|
||||
a[static_cast<std::size_t>(forward[static_cast<std::size_t>(slot)])] = slot;
|
||||
return a;
|
||||
}();
|
||||
return inverse;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
note::Division bakeHoldFromNorm(double norm) {
|
||||
if (!(norm > 0.0)) return note::divisionAt(bySlot()[0]); // also catches NaN
|
||||
if (norm >= 1.0) return note::divisionAt(bySlot()[kLastSlot]);
|
||||
// Round to nearest so each rung owns an equal slice of the knob's travel.
|
||||
const int slot = static_cast<int>(norm * kLastSlot + 0.5);
|
||||
return note::divisionAt(bySlot()[static_cast<std::size_t>(slot)]);
|
||||
}
|
||||
|
||||
double bakeHoldNorm(note::Division hold) {
|
||||
const int slot = toSlot()[static_cast<std::size_t>(note::divisionIndex(hold))];
|
||||
return static_cast<double>(slot) / static_cast<double>(kLastSlot);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,20 @@
|
||||
#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 itself is
|
||||
// musical_division's; only the knob's travel ORDER is decided here.
|
||||
|
||||
#include "core/instrument/note/musical_division.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// [0,1] across the ladder ordered by LENGTH, shortest first — NOT the ladder's picker order,
|
||||
// which is presentation order and would shorten the note at every rung boundary. 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 kChanSegH = 18;
|
||||
constexpr int kVelCellW = 56;
|
||||
constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell
|
||||
constexpr int kVelLabelH = 16;
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
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 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
|
||||
// velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
|
||||
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake, hold.
|
||||
// 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 navTop = topFor(navH);
|
||||
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,
|
||||
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
|
||||
// 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());
|
||||
|
||||
if (r.controls.empty()) return r;
|
||||
|
||||
@@ -18,7 +18,16 @@ inline constexpr int kBakeButtonWidth = 54; // the resample-bake trigger
|
||||
struct ChromeRects {
|
||||
Rect toolbar; // full-width top row
|
||||
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 drawn for a looped Gate sound (bake_plan.h's bakeWindowNeedsHold). It is the
|
||||
// LEFTMOST member of the right-anchored run, so no other member moves either way — what
|
||||
// the reservation buys is a title slot that holds still: dialling a loop in or out would
|
||||
// otherwise re-measure and re-ellipsize the capture name. The cost is ~62 px of dead
|
||||
// toolbar in the common non-looped case.
|
||||
Rect holdCell; // ---- the right-anchored run, left to right ----
|
||||
Rect holdKnob;
|
||||
Rect holdLabel;
|
||||
Rect bake;
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
|
||||
Reference in New Issue
Block a user