Merge Phase Gamma: the ReaSampler 9000 instrument grows a master bus, a real time-stretcher, a reflowed deck, and 44 host-automatable parameters

Four waves, fifteen tracks. dev's Phase E/P work was merged in first and resolved on the branch; this bubble lands the combined tree. In-DAW verification still outstanding.
This commit is contained in:
2026-08-02 22:14:06 -04:00
157 changed files with 17661 additions and 3123 deletions
+6 -4
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-six per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
Per-module detail — what each file owns, its invariants — lives in the twenty-seven per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions
@@ -84,18 +84,19 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-six directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-seven directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope |
|---|---|
| `src/app/` | REAPER extension entry point |
| `src/core/audio/` | pure audio-data math |
| `src/core/capture/` | pure logic behind the capture pillar |
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) |
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / param / ui) |
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
| `src/core/instrument/engine/loop/` | the sustain loop's ONE validity/clamp fold plus its pre-seam crossfade geometry and the editor's default handle span |
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
| `src/core/instrument/param/` | the VST3 parameter surface's pure half — the FOREVER-FROZEN id table, the exposed set derived from the commit predicate, the plain-value layer, and the one formatter per unit category |
| `src/core/json/` | the hand-rolled JSON lexical layer |
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
| `src/core/package/` | the pure RSBK bank-package codec — format contract, version ladder, JSON manifest, framing/layout codec |
@@ -120,7 +121,8 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK
types; `shell/` is where those host types are actually touched — the discriminator is "may this
file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the
table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces
table above); `core/instrument/` further subdivides into `bake/` / `engine/` / `map/` / `note/` /
`param/` / `ui/`. Namespaces
mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
+11
View File
@@ -12,6 +12,17 @@ function(reasampler_pure_library name)
if(ARG_LINK)
target_link_libraries(${name} ${ARG_LINK})
endif()
# A default-less switch missing an enumerator: MSVC's C4062 is off by its /W1 default;
# GCC/Clang's -Wswitch is on by default but only warns without -Werror, and this repo
# sets no -Wall/-Werror/-W4/-WX anywhere. Promoted to an error only here, on our own
# pure libraries, so a deliberately default-less switch (e.g. deckParamCommit,
# deck_groups.cpp) is a compile error on every toolchain. NOT C4061 (fires even with
# a default: present) — that would light up every defensive switch in the tree.
if(MSVC)
target_compile_options(${name} PRIVATE /we4062)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${name} PRIVATE -Werror=switch)
endif()
endfunction()
# Test naming is exceptionless: target <name>_tests is built from tests/test_<name>.cpp
+478
View File
@@ -6,6 +6,42 @@ original Goal, Verify, and checklist points with boxes marked done.
This file holds the current (1.x) cycle's landed milestones only. For all
pre-1.0 (version-0) history, see `docs/ARCHIVE.md`.
### Decouple the instrument reload from VST3 activation (filed follow-up, discharged in Γ-W3)
`ReaSamplerProcessor::setActive` meant two things at once — "the audio thread may run" and "the
decoded `SampleData` is (re)built" — so every host-driven activation cycle paid a bridge read
and a full WAV decode that nothing about activation required. The two lifetimes are now
separate: `setActive(false)` parks the decoded sample and destroys the voice state,
`setActive(true)` rebuilds the voices around the parked sample through the drain-slot swap
`rebuildVoiceEngine` already used for voice-count edits. A cycle costs no disk I/O and no
decode; sounding voices are still destroyed across it (a surviving `live_` would be displaced
into the drain slot and resurrect stale sustained voices as ghosts); an instance with nothing
decoded still routes through the full reload, which is where the pre-v10 legacy lift lives; and
`getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The build
shared by the reload, the voice-param rebuild and the reactivation was factored to one site so the three cannot drift
on the generation stamp or the ring size. Daniel reversed the deferral (*"I thought we agreed
to decouple the unnecessary functions from the reactivation path"*); Γ-F2 and Γ-F6 are untouched
— dynamic latency ships, the deactivate/reactivate is still the accepted cost of the toggle,
just a much cheaper one.
**`reloadInstrument` did three things, not one, and the resume path had to keep all three.** The
first review pass discussed only the pre-v10 legacy lift; the other two were dropped silently and
restored in the follow-up. `refreshRefsFromBank` — the recapture sync — and `publishUsage` — the
`rsusage_` prune-protection write — are each a `GetProjExtState` plus a parse, neither disk nor
decode, so both run on the resume path and the spec's "no disk I/O and no WAV decode" still holds
exactly. This matters only with **no editor open**: `pollBankSync`, the only other route to
either, has exactly one caller and it is the editor's sync tick. Restoring the refresh alone
would have been worse than dropping it — the refs would name a recapture's new file while the
parked PCM still played the old one — so the resume compares the selected ref across the fold
(`sameDecodeSource`, `core/instrument/map/sample_map`) and hands back to the full reload when it
moved. The decode is eliminated in the case that matters and re-run in the case that needs it.
Three smaller consequences fell out of the same pass: `setActive` now treats a repeat of the
state it already holds as a no-op (the base is an empty stub, so a repeated deactivate would have
parked an empty optional over a still-valid sample); the park is disengaged before the build
rather than left moved-from, so a throwing build cannot publish permanent silence; and
`flushLatencyRestart` checks `restartComponent`'s `tresult` and rolls its announcement back on a
refusal, since a latch on a value the host never took would strand its delay compensation.
### Comment-reduction pass (tree-wide, twelve parallel tracks)
Cut source comment volume tree-wide: 209 files changed, net **6,493** lines.
@@ -907,6 +943,448 @@ is recorded in `docs/VERIFICATION.md`; `docs/verify-track-scope-multitrack.md`
is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track
refusal specifically. No human has observed any of these seven behaviors in a DAW.
### Γ-W1-T1 — knob-interaction-law
One consistent interaction and taper law across every variable control in the instrument,
landed before any new control (Rate, Pitch) or VST3 parameter existed so both are authored
into it rather than retrofitted. The taper is extracted into its own pure module,
`core/instrument/ui/param_taper` — the norm↔value maps (ms knobs log-scaled, semitone knobs
log2/centre-expanded) and the modifier vocabulary (`DragModifiers`, `kFineDragScale`, the
four whole-unit Shift snaps), now the single home three consumers read: the knob's needle
(`deck_values`), the AHDSR overlay's schematic axis and its drag inverse
(`envelope_overlay`/`envelope_edit`), and — from a later wave — the VST3 host's
`toPlain`/`toNormalized`. Shift snaps to whole units in the control's displayed category
(ms, semitones, percent, curve-exponent, dB); Ctrl scales the drag by 0.05; Shift+Ctrl
resolves to Shift; a mid-drag modifier press or release re-anchors value and cursor
position so the rate changes without a value jump. `resetDeckParam`'s taper bypass —
writing the default directly rather than round-tripping through `norm → value` — is now
mandatory rather than merely convenient, since the 10 s ceiling is not a power of two the
way the retired 2 s one was.
**The stage-time ceiling moves 2.0 s → 10.0 s** (`kGateStageMaxSeconds` /
`kEnvTimeMaxSeconds`, moved together so they cannot drift), reversing Γ-F3 on Daniel's
later ruling. The AHDSR overlay's schematic axis was re-derived against it: a stage's slot
width is now `slotPx × taperNorm(seconds)` rather than a linear fraction of the ceiling, so
a node's position within its slot is its knob's needle position and a short attack stays
legible at the raised ceiling instead of collapsing under a pixel. Every default gains an
exact normalized preimage under its own taper — the requirement Γ-W4-T1's
`defaultNormalizedValue` depends on, since a host's reset-to-default has no
`resetDeckParam` bypass to fall back on. The filter's four `*Norm` controls (cutoff, Q,
morph, drive) are untouched — their laws are wire-frozen in payload v9 — and the change is
persistence-neutral throughout: the payload stores raw engine doubles, so a project saved
at the old ceiling reloads with identical stored seconds and identical audio.
### Γ-W1-T2 — master-bus-audio
The master bus: a bypassable true-peak limiter, the meter's audio and publication half, and
the plugin's first latency report. New pure modules `core/instrument/engine/limiter` and
`core/instrument/engine/meter_ballistics`. Chain: voice mixer → master gain → limiter →
output bus, with the meter tapped post-limiter. The limiter is a single toggle with no
configurable controls — a baked 0.3 dBTP ceiling, default off, no makeup gain of any kind,
stereo-linked detection so the image never moves. True-peak detection is a 4x-oversampled
sidechain-only detector; the signal path itself is never oversampled. Its gain law is a
sliding minimum of the per-sample target over the lookahead window followed by a moving
average of the same width — every term of that average is a minimum whose own window
contains the sample being gained, so the ceiling holds structurally rather than by a tuned
attack, and the release only ever slows the rise. Switching is a mute, never a blend: the
unlimited signal is emitted at weight 1 or weight 0 and never in between, so the fade
always rides the limited path and the hard edge always lands on the bypassed side.
**Dynamic reported latency**`getLatencySamples()` returns 0 with the limiter off and the
lookahead in samples with it on, driving `restartComponent(kLatencyChanged)` on toggle — is
the plugin's first latency reporting of any kind; nothing in `src/` called
`restartComponent` before this track. Per block the processor publishes relaxed-atomic
peak, clip flag, and max gain reduction with no dB conversion or ballistics on the audio
thread; `meter_ballistics` (pure, unit-tested) does the conversion — instantaneous rise,
20 dB/s fall, a 1.5 s peak hold releasing at the same rate, linear-in-dB scale over
60…+6 dBFS, and a clip latch cleared on request. **Spent the phase's first payload rung**:
`kParamsPayloadVersion` reaches **15**, appending the limiter enable flag as a strict
suffix; a pre-v15 blob lifts to bypassed.
### Γ-W1-T3 — contour-trace-curves
Staged envelope segments now draw as the curve their exponent defines, closing the defect
where the mid-segment knot floated off its own trace — the paint path dropped knots and
joined the remaining vertices with straight strokes even though the exponent was already
in scope, while knot *positioning* had honoured it since Θ-W3-T2. A new pure module,
`curve_tessellate`, joins the non-knot vertices along the same curve `envelopes.h`'s
evaluators use — one point per pixel column at `start + (end start) × curveMap(phi)`
so the drawn stage and the sound it makes cannot diverge; a neutral exponent or a
zero-level span still emits just the two endpoints, matching the straight stroke drawn
before curves existed. All three envelopes (amp, pitch, filter), both play modes, every
sloped stage, share the one fix.
### Γ-W1-T4 — editor-floor-and-row-law
Commits the editor's canvas — the window floor, the width budget it derives from, and
which row each deck group belongs to — so every later UI track in the phase is drawn and
judged at the final window size rather than a size a subsequent wave changes under it.
`kEditorMinWidth` moves 980 → 1190, staying in `sample_bands.h`; `kEditorMinHeight` stays
680 (Γ-F1). `kEditorCeilingWidth` (1280, the hard cap the floor may not exceed) relocates
from `knob_deck.h` into `sample_bands.h` alongside the min-width/min-height pair, since it
is a window fact rather than a deck one; the deck's own width-budget constants — the row
block (1020) and MASTER's reserved width (142) — stay in `knob_deck.h`. The floor is
derived rather than asserted as a literal: `1020 + 12 (gap) + 142 + 2×8 (pad) = 1190`,
leaving 90 px of headroom against the 1280 px ceiling. (**Both numbers moved afterwards:**
Γ-W3-T1 widened the block to 1028 and the floor to 1198 — 82 px of headroom — so the
justification law makes the filter tie-line exact. This paragraph is what W1-T4 landed.)
Row membership becomes a property of
the group id — `DeckRow { Sound, Contour, Spanning }` plus `deckRowFor(DeckGroupId)`, an
exhaustive switch (Sound = PITCH/RATE, FILTER, VELOCITY, VOICE; Contour = PITCH ENV, FILTER
ENV, AMP ENVELOPE; Spanning = MASTER) so a future group left unclassified is a compile
error. Nothing consumes the predicate yet — the two-row arrangement inside this canvas is
Γ-W3-T1's — so at the new floor the deck still packs by the unchanged greedy whole-group
wrap, landing on two rows rather than three; the composition is knowingly interim (PITCH
ENV sits with the sound decks, both rows left-packed with dead space) until Γ-W3-T1 lands
the reflow. No drawing code, descriptor, parameter, or audio changed.
### Γ-W1-T5 — preserve-time-stretch
A real pitch-preserving time-stretcher for Preserve mode, landed a wave ahead of the Rate
control that will drive it so Rate ships onto a finished engine instead of a disposable
stand-in — moved up from a later wave on Daniel's ruling that it was the phase's longest
pole and had no UI dependency. The write rate (duration) and the tap rate (pitch) are
independent, which is the whole mechanism: a new header-only pure module, `time_stretch`,
holds `StretchCursor` — the per-output-frame source-feed schedule, a fractional cursor
carrying its rate debt, loop-wrapped — plus the measured rate bounds and their clamp,
alongside `pitch_shift`'s existing shift-ratio control. Rate 1.0 is exactly one source
frame per output frame with no residue, which is what makes the unity-ratio Preserve read
bit-identical to the pre-stretch engine — the regression floor the track is gated on, since
nothing publishes a non-unity ratio until Γ-W2-T1's Rate knob exists. No new third-party
dependency, no allocation or lock in `process()`, no dispatch on the per-sample path,
buffers sized at voice allocation or reload on `pitch_shift`'s existing pre-warm precedent.
### Γ-W1-T6 — exhaustive-switch gate on pure libraries
Merged as `ee839cf`. **This track has no entry in `docs/PLAN.md`** — the plan's Γ-W1 wave
header states so directly ("the phase's track numbering runs to T7; T6 landed within this
wave but has no entry in this document") — so this record is reconstructed from
`cmake/reasampler_targets.cmake` and the enforcement comment at its confirmed call site,
`src/core/instrument/ui/deck_groups.cpp`, rather than from a spec section.
`reasampler_pure_library()` (`cmake/reasampler_targets.cmake`) now promotes a default-less
`switch` missing an enumerator to a compile error on every pure library: `/we4062` on MSVC,
`-Werror=switch` on GCC/Clang. Neither fired before this track — MSVC's C4062 is off at the
repo's `/W1` default, and GCC/Clang's `-Wswitch` warns without `-Werror`, which this repo
sets nowhere else. The gate is deliberately **not** C4061, which fires even on a switch
that already has a `default:` clause — that would light up every defensive switch in the
tree instead of catching only the deliberately default-less ones, such as
`isLiveDeckParam` and `deck_groups.cpp`'s live-param routing, where a newly added
enumerator must be a compile error rather than a silent fall-through. Later Γ-W1 work
(T4's `deckRowFor`) relies on this gate being in place.
### Γ-W1-T7 — psola-preserve
Preserve's splices become pitch-synchronous. A new pure module,
`core/instrument/engine/period_detect` (two-pass YIN — a decimated cumulative-mean-
normalized difference picks the period, then the full-rate difference function refines it
to a fraction of a frame), estimates the source's fundamental period once at load;
`pitch_shift`'s splice jump becomes the multiple of that period nearest the fixed window
that still fits the ring's jump bound, so an aligned landing point sits at the centre of
the existing correlation search instead of possibly not existing inside it at all.
Detection runs off the audio thread by link graph — `sampler_core` does not link
`period_detect`, so no translation unit on the render path can name `detectPeriod` — and an
unknown period (noise, polyphony, percussion, a drifting source) restores the fixed-window
geometry byte for byte. A period is derived from the audio at load, so it is cache rather
than state: no `ComponentState` field, no payload rung. Merged as `7a162a5`.
**Status, corrected against what `docs/PLAN.md` currently states — the closure is now
complete, not partial.** The **geometry** failure mode (no phase-aligned landing existing
inside the search window at all, for low material such as a 30 Hz tone) was closed and
asserted at the original merge and stands unchanged. The **cadence** failure mode —
splices recurring faster than the output period at rate/shift combinations where
`window/|rate shift|` is short — was left "not closed, re-characterized rather than
fixed, no-regression asserted rather than improvement claimed" at that point, pending
remediation. Three remediation commits have since landed, including `f66b9bd` (correcting
the agreement denominator to count only probes that carried signal) and `83e7cca` — the
commit that closes the track, current tip of this merge — and a re-review confirmed the
earlier findings closed. The re-review found the original cadence analysis itself stale:
PSOLA made the splice interval follow `spliceJump()` rather than the fixed `window` the
module header still described, and the closeout supplied the missing measurement in the
collapse band. **Measured at P = 1470 frames (30 Hz at 44.1 kHz), rate 2.0 / 24 st,
against a known-answer metric floor of 55.86 % and a period-off control arm: fixed-window
excess 18.52 %, pitch-synchronous excess 0.00 %** — PSOLA eliminates that corner rather
than regressing it, with every splice at n = 1 landing exactly one source period away.
**These figures are a one-machine, Debug-build measurement against the named control arm,
not a general performance claim.**
### Γ-W2-T1 — pitch-rate-deck
PITCH became PITCH/RATE: three knobs (`Key Trk | Rate | Pitch`) under the existing
Varisp|Presrv toggle, both new controls wired through the engine. Rate is 50200 % on a
taper linear in semitones over ±12 (the stated exception to the centre-expansion law),
note-on latched; Pitch is a ±24 st baseline offset, live. Keytrack × rate × pitch-offset
compound into a single read-increment multiply — the per-sample path gained nothing.
Merged as `9dbb8b8`. Spent the phase's second payload rung, v16, as a strict suffix; a
v15 blob lifts to rate 100 % / pitch 0 st.
**`isLiveDeckParam` was renamed `deckParamCommit`** and now returns a three-state
`LiveCommit` (`Live` / `NoteOnLatched` / `Reload`) rather than a bool — one predicate
widened, not a second mechanism. **Γ-W4-T1 derives the VST3 exposed parameter set from
this predicate**, so the classification is load-bearing two waves out.
**The clamp question the plan left open at `[propose at review]` was resolved as one
clamp:** `clampStretchRate` at the stretcher, with the taper taking its bounds as
parameters and `deck_values` aliasing them from `engine::kStretchRateMin`/`Max`.
**Code review found one Critical, fixed before merge:** the resample bake derived its
frame window without the two new fields while rendering *with* them, so a bake at any
non-unity Rate — or, under Varispeed, a downward Pitch — wrote a truncated file into the
bank. Fixed by deriving the window from the rate the voice actually reads at; the
regression test judges against a measured reference render rather than a recomputed
formula, and was proved non-vacuous by reverting the fix (every non-unity case fails).
**A ruling folded in during remediation:** Pitch was an uncompensated stage-time coupling
under Varispeed — a Trigger AHD's wall-clock attack was invariant under Rate but scaled
with Pitch. Pitch is now compensated; key-track and velocity remain deliberately
uncompensated, because those are shipped sounds whose compensation would break
bit-identity at non-root notes.
**A Varispeed golden hash was added**, honestly labelled: unlike the Preserve constants
(witnessed against pre-track commit `0a7778b`), it was captured from the remediation
commit itself, so it stands as a witness for the *next* track rather than proof of this
one.
### Γ-W2-T2 — loop-crossfade-ux
An explicit loop enable, a legible mark grammar, and the crossfade painted where it is
heard. **No format change** — no `ComponentState` version moved, no new persisted field,
`resolveLoop` untouched, audio unchanged. The enable **is** `SampleLoop::hasLoop`, whose
provenance changes from marker-gesture-derived to user-owned, with the gestures as
shortcuts onto it. Merged as `a8e30a9`. A new pure module,
`core/instrument/ui/loop_marks`, holds the state machine (`resolveLoopMarks`/
`applyLoopMarks`); the four marks (START/LOOP/END/XFADE) get one grammar — line + shaped
cap + label, the cap being the grip — with cap/label/suppression geometry pure and
unit-tested.
**The crossfade moved to `[loopEnd crossfade, loopEnd)`** and draws as a
top-and-bottom edge wedge, never a second fill; the loop fill's peak alpha stays exactly
0.20.
**START draws in `overlay/trace`, NOT `accent/primary` as the spec table states**
because `accent/primary` *is* the waveform fill, so a primary START would measure 1:1
against the material it marks. `overlay/trace` measures 3.071:1 against the fill and
3.065:1 against `bg/base`, clearing the 3:1 non-text floor on both. **This is a
deliberate deviation from `docs/product/instrument-control-surface.md` §6.3's table**,
and the spec is what is wrong.
**Code review found three Majors, all fixed before merge:** a parked-vs-off state leak
where dragging START on a fresh capture silently converted "never set" into "LOOP OFF";
three text draws landing on the lime waveform fill at 1.58:1 and 1.10:1 (fixed with a
`bg/base` scrim at alpha 0.90, solved for the binding case rather than chosen by eye — it
lands at 4.666:1 / 8.091:1 and is pinned in `test_theme.cpp`); and a per-mouse-move
bridge read plus bank parse in the hover path, now memoized against the existing key
struct.
**`docs/TODO.md`'s "Pre-existing staged-envelope-node shadow at zero-attack" entry was
resolved incidentally** — giving START a cap is what closed it — and rewritten in place
with the recorded outcome by the track itself.
**One thing is deliberately NOT settled and is recorded here as open, not as accepted:**
the audible wedge draws `accent/secondary` against the `overlay/trace` envelope trace at
**1.60:1** against a 3:1 floor. The 0.20 fill-alpha constraint is met and the
pre-existing accepted 2.25:1 pair is unmoved, but the under-floor *extent* inside the
loop span grows from two 2 px marker columns to two `crossfadeWidth × 10 px` bands. No
alpha fixes it — the pair is intrinsic to teal-on-violet. **Daniel has this: he is
judging it visually in the DAW and has not yet ruled.**
**Neither track has been verified in a running DAW; both are asserted in CTest only.**
The full test suite passes on the merged result — **99/99, Debug config, on one
machine** — not a general cross-platform or Release-config claim.
### Γ-W3-T1 — deck-reflow
The knob deck's row law stops being a wrap outcome and becomes a property of the group
descriptor, by construction: two categorical rows — Sound (PITCH/RATE, FILTER, VELOCITY,
VOICE) and Contour (PITCH ENV, FILTER ENV, AMP ENVELOPE) — plus a double-height,
right-anchored MASTER bus deck outside both, carrying the limiter enable toggle, one
reserved cell, the output meter column, and a passive gain-reduction lamp. `DeckRow {
Sound, Contour, Spanning }` and `deckRowFor` (`ui/deck_groups`) are an exhaustive switch
over every `DeckGroupId`, so a group added later without a row assignment is a compile
error; the greedy whole-group wrap this replaces is gone entirely, not merely unreached at
this width.
FILTER's `Band|Notch` toggle moves from the knob row into its own caption's previously
unused second toggle slot, taking the group from 524 to 432 px (92) — the reduction that
lets row 1 (980 px natural) fit inside the row block. VOICE deliberately keeps its
`Retrig|Legato` row toggle rather than following suit: moving it to the caption would make
VOICE *wider* (226 px vs. 164), since its caption row is already the binding side.
**The row block widened 1020 → 1028 px and the editor floor moved 1190 → 1198 px
(Daniel's ruling, 2026-08-02).** The originally specified 1020 could not simultaneously
deliver the filter tie-line (both rows' FILTER/FILTER ENV right edges landing at the same
x) and equal, no-narrower-than-12px gutters on both rows — the three properties were never
jointly satisfiable at that width. At 1028 all hold: row 1's three gutters land at
16/16/16, row 2's two at 76/76, and both FILTER and FILTER ENV land their right edge at x
= 640. Ceiling headroom against the 1280 px cap is now 82 px.
The MASTER meter's per-block state moved from a plain overwriting store to an accumulated
one: at 48 kHz/512-frame blocks, roughly 47 blocks elapse between two 500 ms UI ticks, so
the overwriting store had displayed one block in ~47 and dropped the rest. The processor
now folds a per-channel peak max and a limiter min-gain across the whole interval, and the
consuming `masterBusMeter()` read clears the accumulators as it drains them.
**The instrument reload was decoupled from VST3 activation as part of this track**
`setActive(false)` now parks the decoded `SampleData` and destroys only the voice state,
`setActive(true)` rebuilds the voices around the parked sample, so a host-driven
activation cycle costs no disk read and no WAV decode. This discharges the `docs/TODO.md`
follow-up already recorded in full detail at the top of this file ("Decouple the
instrument reload from VST3 activation") — not restated here.
The limiter toggle's commit is split so that cheaper cycle stays off the mouse handler:
the audible state — the parameter, the audio-thread mirror, the latency reader — commits
inline on the click; only the host's `restartComponent(kLatencyChanged)` notification is
deferred, drained by the editor's existing 500 ms sync tick.
**Not verified in a running DAW — CTest-asserted only:** the meter at its 500 ms UI
cadence, the GR lamp under real limiter action, the limiter toggle's latency
renegotiation, the clip cap's click-to-clear, and the recapture-while-editor-closed path
(the bank fold and its predicate are unit-covered; the activation that drives them is
not).
### Γ-W3-T2 — bake-reset-amendment
The correction Phase Γ owed Phase Ξ: Ξ-W2-T1's bake shipped ahead of the sequencing this
plan asserted, so its reset list predated rate, pitch offset, the limiter enable, and the
loop enable. The finding, on reading what actually shipped: **`resetAfterBake` needed no
code change.** All four already reset by construction — none was ever added to the
survivor copy-back list, and the function's shape is "default everything, copy back only
survivors," so anything never named a survivor already resets. The track shipped
field-by-field assertions over two independently-dialled fixtures (never struct equality,
which would pass while silently letting a survivor slip through undetected) plus a
spot-check sweep confirming both fixtures actually moved every asserted field off its
default, so the coverage is mutation-verified rather than merely present.
**One invariant correction:** `bake/CLAUDE.md` had claimed the whole signal chain prints,
master gain included. It doesn't — the render's gain multiply is the only master-stage
value it prints; the limiter runs in the processor's block, off the bake path entirely.
The claim is now scoped to gain alone, with an explicit note that "the bake prints the
gain" does not generalize to the rest of the master stage.
**Outstanding, not closed by this track.** The limiter's exclusion from the printed master
stage is a real audible gap — a capture baked with the limiter engaged comes back
unlimited — and Daniel has ruled that a future track will change the bake to print the
limiter. Until that lands this is a recorded, known limitation, not an oversight.
**Neither track has been verified in a running DAW; both are asserted in CTest only.**
### Γ-W3-T3 — bake-prints-limiter
The bake's master stage now prints the limiter as well as the gain multiply, closing the
audible gap Γ-W3-T2 recorded and left open: a capture baked with the limiter engaged
returns limited audio rather than unlimited audio. `renderBake`
(`core/instrument/bake/bake_render.cpp`) instantiates its own `Limiter` — the same
bake-only-engine precedent its `VoiceEngine` already set — never linked into
`reaper_reasampler`; `src/app/CMakeLists.txt`'s exclusion comment names the limiter
alongside `sampler_core`/`pitch_shift`/the filter, so the extension's link graph gains no
new edge.
**The lookahead needed compensation, which was open at spec time.** The limiter delays its
output by `kLimiterLookaheadSeconds` (0.002 s = 96 samples at 48 kHz), so the render's
buffers carry `renderFrames() + flushFrames` frames, the extra span fed silence rather than
more rendered audio, and the capture is read out starting at `leadInFrames + flushFrames`
instead of `leadInFrames` alone — the file is the same frames it would be bypassed, not the
same capture shifted 2 ms late.
**A sequencing trap, recorded inline at the call site.** `Limiter::prepare()` ends by
calling `reset()`, which snaps to whatever the enable target already is, so the render calls
`setEnabled(true)` before `prepare()`. Reversed, the limiter would take its live-engage path
instead — `process()`'s prime-then-fade — muting and then fading in the first ~12 ms of
every capture (the delay-line prime plus `kLimiterMuteSeconds`, per `limiter.h`).
**The bypassed path is unchanged.** `test_bake_render.cpp` asserts bypass ≡ engaged
bit-for-bit under the ceiling (a ramp fixture at unity gain, verified against the source
sample for sample too) and separately confirms the printed-limiter path holds the ceiling
and stays stereo-linked under a DC fixture driven well past it; repeat bakes stay
bit-identical with the limiter engaged as well, since `renderBake` builds a fresh `Limiter`
per call and `prepare()` zeroes every one of its state fields.
**Double-limiting is a named boundary, not a defect** (`bake/CLAUDE.md`): a printed capture
replayed through an engaged limiter is limited twice. The post-bake reset ordinarily
prevents it, since `limiterEnabled` is not on the survive list.
**`bake/CLAUDE.md`'s invariant is corrected alongside the code.** The text Γ-W3-T2 left in
place ("the limiter is not printed") is replaced with "the whole chain is printed — voice,
master gain, then the limiter, in the processor's own order," and the track's three
`[propose at review]` open questions are answered inline in the same section: the bake
instantiates its own `Limiter`; the lookahead does need in-render compensation, exactly the
above; and yes, this track also corrects the invariant text rather than leaving it to a
later pass.
**Not verified in a running DAW — CTest-asserted only.**
### Γ-W4-T1 — vst3-parameter-set
The instrument now reports its automatable parameters to the host: 44 of 44 issue, under a
FOREVER-FROZEN `ParamID` table — blocks of 100 per deck group in signal-flow order, steps
of 10 within a block, a curve dial at its outer knob's id + 1 — each with a real plain
range, `units` string and display precision at the host boundary, not a raw normalized
float. The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never
hand-maintained: a control qualifies iff its class is `Live` or `NoteOnLatched`. Everything
else — play mode, the pitch engine, filter enable, the three Staged↔Spline toggles, voice
count, Poly|Mono, Retrigger|Legato, and the limiter enable — is OMITTED from the list
entirely rather than exposed read-only, a named limitation rather than a silent one. A new
pure module, `core/instrument/param` (`param_id`, `param_units`, `param_format`,
`param_live`, `param_merge`), holds the id table, the plain-value layer, the one formatter
per unit category (eight of them), and the audio thread's block-boundary merge decision;
`shell/instrument/instrument_params` adapts it onto `Steinberg::Vst::Parameter` and decides
nothing itself.
**Both VST3 delivery channels are serviced.** An earlier pass routed host automation
through `IEditController::setParamNormalized` alone — the SDK documents that as the
GUI-update channel only ("should update the according GUI element(s) only") — while
`ProcessData::inputParameterChanges` is the audio-side one; the SDK's own
`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`)
drains the queue in `process()` *and* implements `setParamNormalized`. Both are now
serviced.
**A host automation point's authority is bounded, not permanent.** It outranks the model
only between the point landing and the UI thread folding it into the model and
republishing — at most one UI tick — never a later restore, bake reset, or knob move. An
earlier pass made the hold permanent, which silently defeated `setState`, preset load,
undo, and the bake's reset for any parameter that had ever carried an automation point.
The model is now written down in full — `shell/instrument/CLAUDE.md`'s "THE AUTHORITY
MODEL" section — and enforced by the pure `param_merge`; `test_param_merge` asserts both
halves: that a held point outranks the model until the model catches up, and that a writer
after the release reaches the audio again.
**Two rulings, both Daniel, 2026-08-02.** (1) Pitch key-track and Trigger length promote
from `Reload` to `NoteOnLatched` — the promotion that takes the count to 44 of 44 and
issues ids 1000 and 1450. It was **not** the predicate-only change the plan anticipated:
key-track lives on `InstrumentParams`, not `PlaySeconds`, so the host's write path could
not reach it without `LiveValues` and `foldLive`'s input widening and `Voice::start`
taking the two latched values as arguments beside rate; the new `param::valueHomeFor`
guard closes the class of bug this exposed (a promoted control with no home would have
no-oped silently in both directions) by asserting every exposed control has a home and
branching the shell's own read/write paths on it. (2) The curve-shape dials' ±0.01
snap-to-centre band now applies on the mouse-drag path only, never on a host-facing map —
*"our continuous ranges should be continuous."*
**Two adjacent SDK surfaces were assessed and left unimplemented, with dispositions
recorded rather than re-surveyed later.** `IMidiMapping` — no CC vocabulary fits what's
exposed, and REAPER's own per-parameter MIDI learn is expected to cover the case.
`IParameterFunctionName` and `IAutomationState` are also not implemented; the latter
reports the host's automation mode for the whole plug-in, not per parameter, so it cannot
answer the bake's "is this parameter automated" question.
**The bake's reset now notifies the host, and its one remaining gap is named rather than
hidden.** Every internal writer of an exposed parameter's value goes through the one
`beginEdit`/`performEdit`/`endEdit` path, the bake's reset included. What it cannot do:
clear a host automation lane. If a reset-class parameter carries one, the lane replays its
curve onto audio the bake already baked that processing into — double processing — and
`IAutomationState`'s whole-plugin (not per-parameter) granularity means there is no way to
detect or refuse it. Documented as a boundary of the bake's fidelity claim, not discovered
later as a bug against Phase Ξ.
**The per-sample voice path is byte-identical across the whole track.**
**Not verified in a running DAW — CTest-asserted only.** `docs/TODO.md` carries the
residual DAW-verification items: whether REAPER renders `ParameterInfo::units` beside the
formatted string, whether REAPER's MIDI learn actually covers the un-shipped `IMidiMapping`
case, the three migration round trips (a pre-parameter project, a save/reopen in an older
binary, automation drawn and replayed), whether an offline render replays automation, and
whether REAPER restores instance state through `setState` rather than `setComponentState`.
### Phase Ρ — Render in place: a track's output to a new sibling, source to the bench
One wave, one track (Ρ-W1-T1 `render-in-place`), code-complete, reviewed, remediated, and
+268 -1199
View File
File diff suppressed because it is too large Load Diff
+44 -54
View File
@@ -158,13 +158,7 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected.
**Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart.
**The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules.
**Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all.
**Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended.
**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — but the cap slot's own nominal area DID move, from the old clipped-actual measure (60 at frame 0) to the new nominal 110 every cap now feeds it (`markerHandleRect`'s own unclipped area). That move leaves the `cap < node < column` ordering unchanged only because 110 is still under the node's fixed 169 — the outcome held, not the area. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged.
## Active-bank indicator placement (B4 polish)
@@ -232,57 +226,35 @@ alpha and this entry is re-filed against the new value.
**Nothing here is actionable as a TODO.** Delete this entry when Γ-W1-T1 lands.
## Decouple the instrument reload from VST3 activation
## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling
**Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed).** Γ-W1-T2 ships the plugin's
first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead
with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The
vendored SDK defines that flag as a host **deactivate/reactivate**
(`pluginterfaces/vst/ivsteditcontroller.h:105-108`). **Dynamic latency reporting is routine for
VST3 instruments and REAPER handles it as a matter of course** — the deactivate/reactivate is
the normal contract, and for a typical plugin `setActive` only allocates and frees buffers.
Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it:
*"you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC
dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter
will either be on or off on its instance, toggling during playback is not a use case."*
**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the
~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the
Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_`
plus the per-gesture anchors it is read against.
**The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls
`reloadInstrument()` (`reasampler_processor.cpp`'s `ReaSamplerProcessor::setActive`) — a bridge read
plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`,
`draining_` and the graveyard (`ReaSamplerProcessor::setActive`). So every host-driven activation cycle — a
latency-change restart, an offline-render bracket, any host that deactivates around transport
state — pays a disk read and a decode that nothing about activation requires. **Activation
currently means two things at once**: "the audio thread may run" and "the decoded `SampleData`
is (re)built." Dynamic latency is simply the first feature that makes the cycle
user-triggerable.
**Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs**
(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of
the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and
three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant
observed rather than enforced. Extracting it is a real refactor of the editor's input half, not
a header move — and doing it inside a wave whose subject is the MASTER deck would have put an
unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded
was not.
**Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a
deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this
file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the
already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode
edits. This is a lifetime split, not a new mechanism.
**The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload,
with the input TUs mutating it through named transitions rather than assigning `drag_` and its
anchors independently — which is also what would let the invariant "an anchor is only readable
while its own `DragKind` is in flight" be enforced rather than observed. `editor_interaction.h`
already holds the `DragKind` vocabulary and is the natural home.
**The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its
reason is documented at the call site: a surviving `live_` would be displaced into the drain
slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still
die across the cycle** — only the decoded PCM survives, and those are two different lifetimes
currently collapsed into one. Second constraint: `setActive(true)` is also the non-editor
legacy-lift trigger for a pre-v10 blob (its opportunistic `refreshRefsFromBank` copies refs in
once the bank blob is readable), so a path that skips the bridge read must keep that lift
reachable — the comment in `ReaSamplerProcessor::setActive` records the residual load-order race it exists to cover.
**Priority / risk.** Low, but the margin is the clock: the next surface that adds two members to
the header takes it over the ceiling, and at that point the seam gets chosen under time pressure
by whoever is unlucky. Take it before that, not after.
**Priority / risk.** Low; deferred by ruling. Nothing is incorrect today, only wasteful, and
Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter
toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second
latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the
limiter enable is ever wanted automatable, which `docs/product/parameter-automation.md` §3.8
currently forbids *because* of this cost; or (c) the re-decode is observed to be perceptible in
REAPER — Γ-W1-T2's review records that observation for exactly this purpose.
**Done looks like.** A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV
decode; sounding voices are still destroyed across it, with no ghost-resurrection regression;
a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather
than from a transient the deactivate cleared.
**Done looks like.** `reasampler_editor.h` is back under the ceiling with room; no TU assigns
`drag_` and an anchor as two independent writes; and the transitions are named where the
`DragKind` catalogue already lives.
## `Sample::sourceMode` has no value meaning "produced by the instrument"
@@ -366,7 +338,7 @@ The within-deck stacking idea is retired, not deferred.
**The measured-geometry block that used to live here has been deleted, not moved.** It was
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
group's width, both row totals, and the resulting 1190 × 680 floor — is the table in
group's width, both row totals, and the resulting 1198 × 680 floor — is the table in
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
them; it was measured against a layout that no longer exists.
@@ -875,6 +847,24 @@ doc-keeper edit.
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
in the `.rpp`", and does not gain a second home for the distinction.
## The VST3 parameter surface's DAW-verifiable claims
**Context (what shipped — Γ-W4-T1).** The instrument reports 44 automatable parameters under the frozen id table, services both delivery channels (the controller's `setParamNormalized` and the audio thread's `IParameterChanges` drain), and folds automated values back into the blob on the UI thread.
**What is settled without a DAW.** The channel question itself is answered by the vendored SDK, not by observation: `ivsteditcontroller.h` documents `setParamNormalized` as the GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own `SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains `ProcessData::inputParameterChanges` in `process()` while also implementing `setParamNormalized`. Servicing both is what the SDK's own precedent does; it needs no verification, only exercise.
**What genuinely needs a running REAPER, and why none of it can change the design.** Each item below is a host BEHAVIOUR, not a contract — the plug-in is correct under either answer, so discovering the answer costs a display fix at worst:
1. **Whether REAPER renders `ParameterInfo::units` beside the string `getParamStringByValue` returns, or shows the string alone.** We ship the SDK's own convention (digits in the string, unit carried separately). If REAPER shows no unit at all, the fallback is to append the unit inside the one formatter — one line in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen.
2. **Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping` CC table would have.** The decision to ship no default map rests on it; if learn does not reach these parameters, a CC table is additive and frozen by nothing.
3. **That the three migration round trips hold**: a pre-parameter project opens with every parameter reading the blob's value and sounds identical; a project saved by this build restores fully in an older binary; a project with automation drawn, saved and reopened, replays against the same plain values.
4. **That an offline render replays automation** — the sharpest case for the audio-side drain, because the host drives `process()` and may never touch the controller.
5. **That REAPER restores instance state through `setState`, not `setComponentState`.** This is the ENTRY-POINT half of the original bundled `[verify, FIRST]`; the pass that closed that item closed only its delivery-channel half, which is a different question. The evidence short of a DAW is strong but is inference: `vstsinglecomponenteffect.h:41-47` collapses the two names on a single-component plug-in, our `setState`/`getState` overrides land on the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`, and the blob has round-tripped through payload v1…v16 in real projects. Exercising it costs one save/reopen.
**Priority / risk.** Low. Nothing here is load-bearing on the frozen contract: the id table, the plain ranges and the norm↔plain laws are all decided and tested without a host.
**Done looks like.** Each of the four exercised once in REAPER, with the unit-rendering answer recorded and, if it went the other way, the one-line formatter change made.
## `view_mode_model.cpp` is over the ~600-line structural bar, and `view.cpp` is close behind
**Context (surfaced by the FX-GUID keying track).** Root `CLAUDE.md`'s structural
+176 -106
View File
@@ -31,14 +31,15 @@ own width formula, not carried over from a prior measurement. The stale geometry
**PITCH/RATE | FILTER | VELOCITY | VOICE** (sound). Row 2 is **PITCH ENV | FILTER ENV |
AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.**
- **The arithmetic closes, with room.** Minimum/default window goes **980 × 680 →
1190 × 680**, inside the settled 1280 × 720 ceiling with **90 px of headroom**. The deck
1198 × 680**, inside the settled 1280 × 720 ceiling with **82 px of headroom**. The deck
band drops **328 → 216 px**, returning **112 px to the waveform** (246 → 358 px at the
floor). **That 90 px is the governing budget for every future control addition** — one
floor). **That 82 px is the governing budget for every future control addition** — one
deck cell is 60 px, so the layout has room for exactly one more, once. §1.6.
- **The two rows align exactly, not nearly.** At the floor width the row block is 1020 px,
and at that width row 2's two gutters are equal (72 px each) *and* FILTER's right edge
lands exactly on FILTER ENV's right edge (both at x = 636). That is the aesthetic tie
between the rows and it falls out of the arithmetic — §1.3.
- **The two rows align exactly, not nearly.** At the floor width the row block is 1028 px,
and at that width BOTH rows' gutters are equal (16/16/16 and 76/76) *and* FILTER's right
edge lands exactly on FILTER ENV's right edge (both at x = 640). That is the aesthetic tie
between the rows and it falls out of the arithmetic — §1.3, which also records the three
properties the originally-specified 1020 block was claimed to deliver and did not.
- **PITCH becomes PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the existing
Varisp|Presrv toggle. Rate 50200 % exponential, Pitch ±24 st.
- **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter
@@ -49,9 +50,8 @@ own width formula, not carried over from a prior measurement. The stale geometry
when off, the lookahead when on, reported to the host's PDC. This is **routine VST3
behaviour**; the `restartComponent(kLatencyChanged)` it costs is the normal contract, and
the deactivate/reactivate the flag mandates is **accepted** — the toggle is a patch-design
gesture. The only reason the cycle is expensive at all is that **our** `setActive` re-decodes
the WAV, which is a latent improvement filed in `docs/TODO.md`, not a design constraint.
§3.1.1.
gesture. The cycle used to be expensive only because **our** `setActive` re-decoded the WAV;
Γ-W3 decoupled the two lifetimes, so it no longer does. §3.1.1.
- **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing.
- **Loop gets an explicit enable on the chrome row** (Γ-F4), and the four-mark grammar
sits under it. The core finding behind the re-approach: three identical bars draw a
@@ -117,30 +117,31 @@ and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`.
| | Natural content | Gutters at floor | **Row width** |
|---|---|---|---|
| Row 1 | 192 + 432 + 192 + 164 = **980** | 12 + 14 + 14 = 40 | **1020** |
| Row 2 | 252 + 312 + 312 = **876** | 72 + 72 = 144 | **1020** |
| Row 1 | 192 + 432 + 192 + 164 = **980** | 16 + 16 + 16 = 48 | **1028** |
| Row 2 | 252 + 312 + 312 = **876** | 76 + 76 = 152 | **1028** |
**Window floor.**
```
deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174
kEditorMinWidth = 1174 + 2·kPad(8) = 1190
deck band width = 1028 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1182
kEditorMinWidth = 1182 + 2·kPad(8) = 1198
kEditorMinHeight = 680 (unchanged)
deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8) = 216 (was 328)
waveform band at the floor = 680 90 (chrome) 4 4 8 216 = 358 (was 246)
```
**1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.**
**1198 × 680, against a 1280 × 720 ceiling — 82 px of width headroom, 40 px of height.**
> **Who lands which half.** The floor, the three budget constants it is derived from
> (row block 1020 · MASTER 142 · ceiling 1280) and each group's row membership land in
> (row block · MASTER 142 · ceiling 1280) and each group's row membership land in
> **Γ-W1-T4**, in wave 1, so the rest of the phase is authored at the final window. The
> arrangement *inside* that budget — the justification law, the gutters, the tie-line,
> MASTER's interior — is **Γ-W3-T1**, because every one of those measures a descriptor that
> does not exist until Γ-W2-T1 and Γ-W3-T1 create it. **Row 1's natural width does not fit
> the 1020 block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, 92 from FILTER's
> `Band|Notch` caption move, = 980. Row 2's 876 already fits. `docs/PLAN.md` at Γ-W1-T4
> states the seam and the interim layout in full.
> the block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, 92 from FILTER's
> `Band|Notch` caption move, = 980. Row 2's 876 already fits. Γ-W1-T4 set the block at 1020
> and the floor at 1190; the widen recorded below moved both, and it is the ONLY number of
> W1-T4's that this phase reopened. `docs/PLAN.md` at Γ-W1-T4 states the seam in full.
Three corrections to the arithmetic in the brief, all small and all in our favour:
@@ -148,9 +149,13 @@ Three corrections to the arithmetic in the brief, all small and all in our favou
ceiling with zero slack. 142 is what the deck's own content actually needs (§1.4) and
it banks 94 px. MASTER may grow to **236** before the ceiling binds; that is the
meter's growth room, not a target.
2. **The row block is 1020, not 1016.** The extra 4 px is deliberate and is what makes the
two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest
aesthetic purchase in the phase.
2. **The row block is 1028, not 1016 and not the 1020 originally specified.** 1020 was
chosen to make the two rows align exactly; it does not — 1020 leaves row 1 a 40 px slack
that three gutters cannot divide evenly, so the justification law produces 14/13/13 and
leaves FILTER's right edge 2 px past FILTER ENV's. **1028 is the width at which the law
itself makes the tie-line exact**, with no residue in either row (§1.3). The 8 px is the
single cheapest aesthetic purchase in the phase, and it is spent from the headroom
ledger in §1.6.
3. **VOICE keeps its row toggle** — confirmed. Moving `Retrig|Legato` to the caption gives
`38 + 4 + 80 + 4 + 88 = 214`**226 px**, wider than 164, because VOICE's caption row is
the binding side and its knob row is nearly empty. Leave it.
@@ -182,11 +187,24 @@ approximate:
share a right edge.
2. **The filter tie-line.** At the floor width the two rows' filter groups end on the same
pixel:
`row 1: 192 + 12 + 432 = 636` · `row 2: 252 + 72 + 312 = 636`.
That is not a coincidence to be preserved by a special rule — it is what row-block
width **1020** buys, and at 1020 row 2's two gutters are *also* exactly equal (72/72)
and row 1's smallest gutter is *exactly* `kDeckGroupGap`. Three good properties at one
width. **This is why the floor is 1190 and not 1186.**
`row 1: 192 + 16 + 432 = 640` · `row 2: 252 + 76 + 312 = 640`.
That is not a coincidence preserved by a special rule — it is what row-block width
**1028** buys, and at 1028 both rows' gutters are *also* exactly equal (16/16/16 and
76/76), because 1028 leaves each row a slack its gutter count divides with no residue.
**This is why the floor is 1198 and not 1190.**
> **Corrected 2026-08-02 — this paragraph previously claimed THREE properties at 1020,
> and none of the three held there.** It said the tie-line landed at 636, that row 2's
> gutters were equal, and that row 1's *smallest gutter was exactly* `kDeckGroupGap` (12).
> What 1020 actually produced: row 1's slack is 40 over three gutters, so the law's
> equal-division-plus-leftmost-residue rule gives **14/13/13** — not 12/14/14 as §1.2's
> table stated, and not a smallest gutter of 12 — and FILTER's right edge lands on **638**
> against row 2's 636. Only row 2's equal gutters held. The three were never
> simultaneously satisfiable: the tie-line needs 1028, an exactly-12 smallest gutter needs
> 1016, and 1020 delivered neither. **`kDeckGroupGap` is a FLOOR — "no gutter narrower
> than 12" — never a target**, so the 16 px gutters at 1028 satisfy the real rule and the
> third property is withdrawn rather than traded away. Two properties hold at 1028, both
> exactly, and the law is what makes them hold.
3. **Shared horizontal baselines.** Every group is `kDeckGroupH` with identical interior
offsets, so across both rows the caption text, the knob centrelines and the label bands
sit on the same four lines. The reflow must not break this — it is free today and
@@ -249,32 +267,37 @@ Horizontally the group is `6 + 60 + 8 + 62 + 6 = 142`.
| Deck rows at the floor width | 3 (by greedy wrap) | **2 (by construction)** |
| Deck band height | 328 | **216** |
| Waveform band at the floor | 246 | **358** |
| Minimum / default window | 980 × 680 | **1190 × 680** |
| Ceiling headroom | — | **90 px wide, 40 px tall** |
| Minimum / default window | 980 × 680 | **1198 × 680** |
| Ceiling headroom | — | **82 px wide, 40 px tall** |
**Costs, named.** The floor width grows by 210 px — an existing saved instance's window
**Costs, named.** The floor width grows by 218 px — an existing saved instance's window
grows on open (the same one-time effect Θ-W6-T1 already shipped at 840 → 980, so the
behaviour is precedented, not new). The deck's wrap mechanism stops being the thing that
decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom
budget — §1.6.
### 1.6 The 90 px headroom is the budget, and it governs every future control
### 1.6 The 82 px headroom is the budget, and it governs every future control
**Read this before proposing any new knob.** The floor is **1190** against Daniel's hard
**1280** ceiling. That is **90 px of width headroom for the life of this layout**, and it is
**Read this before proposing any new knob.** The floor is **1198** against Daniel's hard
**1280** ceiling. That is **82 px of width headroom for the life of this layout**, and it is
the single constraint every later addition spends from:
| Purchase | Cost | Headroom after |
|---|---|---|
| One more 60 px deck cell on row 1 | 60 | 30 |
| One more caption toggle on a group whose caption row is the binding side | 048 | 4290 |
| Widening MASTER to a two-cell left column | 60 | 30 |
| One more 60 px deck cell on row 1 | 60 | 22 |
| One more caption toggle on a group whose caption row is the binding side | 048 | 3482 |
| Widening MASTER to a two-cell left column | 60 | 22 |
| A second cell *and* a wider MASTER | 120 | **over ceiling** |
**The ledger was 90 until the row block widened 1020 → 1028** (§1.2 correction 2, §1.3). Its
*purchasing power* is unchanged: one more 60 px deck cell remains affordable (82 60 = 22),
which is the only purchase this ledger has ever promised, and the second one was already over
the ceiling at 90. The 8 px came out of the spare change, not out of the budget's one slot.
**This is why MASTER's reserved lower-left slot is ONE cell and not two** (Γ-F5, ruled by
Daniel 2026-08-01). A two-cell reserve would spend 60 of the 90 up front, on a control
Daniel 2026-08-01). A two-cell reserve would spend 60 of the 82 up front, on a control
nobody has named yet, and would effectively freeze row 1 forever: any later row-1 addition
would then need the remaining 30 px and would not have it. One cell keeps the spare. If the
would then need the remaining 22 px and would not have it. One cell keeps the spare. If the
future master-bus control turns out to be two knobs, widening MASTER **then** costs the same
60 px it would cost now, and by then the trade is being made against a real control instead
of a guess. **Reserving capacity you have not designed a use for is not free here — it is
@@ -287,7 +310,7 @@ Two corollaries for a reader who wants to add something:
FILTER's `Band|Notch` move exploits). A cell always costs its 60 px.
- **The chrome row is a separate budget.** The toolbar row's right-anchored control run is
paid for out of the *title* slot, not out of the window floor — which is why the loop
enable (§6.5) costs zero of the 90. That is a genuinely different purse and must not be
enable (§6.5) costs zero of the 82. That is a genuinely different purse and must not be
confused with this one.
---
@@ -344,16 +367,16 @@ shift unless that contingency is taken.
**Settled: Rate is latched at note-on for this phase (not live on sustaining voices).**
Two consequences the implementation must get right:
**It is a latch, not a reload.** `isLiveDeckParam` is currently a binary predicate whose
`false` branch routes an edit to a **full reload** (bridge read, WAV re-decode, fresh
**It is a latch, not a reload.** `deckParamCommit` was a binary predicate whose
`false` branch routed an edit to a **full reload** (bridge read, WAV re-decode, fresh
engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is
therefore a **third commit class**: *published into the live block like any live parameter,
but read only by `snapLive` at note-on and never by `applyLive` on a sounding voice.* The
mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is
exactly this split — but the *classification* does not.
exactly this split — but the *classification* did not, until this phase widened it.
> **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are
> live is ONE decision, recorded in ONE place"* — `isLiveDeckParam` / `liveCommitFor` in
> live is ONE decision, recorded in ONE place"* — `deckParamCommit` / `liveCommitFor` in
> `ui/deck_groups`. Phase Γ widens that one decision from two states to three
> (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere.
> This is also precisely the seam the automation work needs — see
@@ -374,12 +397,23 @@ Preserve it is an addend to a shift amount the pitch envelope already modulates.
### 2.4 Rate scaling — what "scales with rate" means, concretely
- **Loop points scale with rate.** The loop is a pair of *source-frame* facts. Under
Varispeed the read increment changes and the loop is traversed proportionally faster —
scaling is automatic and the stored frames are untouched. Under Preserve the read
advances at `rate ×` the source rate, so the loop's wall-clock period scales by `1/rate`
while its source-frame span is unchanged. **In neither mode are the stored loop frames
rewritten**; the marks on the waveform do not move when Rate moves.
- **Loop points scale with rate under Varispeed; under Preserve, the loop's *traversal*
scales and its audible period does not.** The loop is a pair of *source-frame* facts.
Under Varispeed the read increment changes and the loop is traversed proportionally
faster — scaling is automatic, the stored frames are untouched, and the audible period
scales by `1/rate` along with everything else the voice plays. **Under Preserve this is
the opposite of what the Varispeed case suggests, and the obvious extension of it is
wrong** — which is why an engineer measured this before writing code against it rather
than inferring it from the Varispeed case above. What scales with rate under Preserve is
the loop's *traversal* — how fast the source is consumed (the feed-side witness is
`testPreserveStretchLoopsTheSourceSpan`) — not its audible period: holding the source's
period constant while its duration changes is what Preserve *is*. **Measured** (Debug
build, one machine): with a ring long enough to hold the whole loop, the rendered
sawtooth period is ~3999.9 output frames at rate 0.5, 1.0, and 2.0 alike; at shorter
rings, where splice cadence intrudes instead of the design property being isolated, the
same fixture measured 3064 and 4130 frames at rate 0.5 — never the 8000 a scaling period
would give either. **In neither mode are the stored loop frames rewritten**; the marks on
the waveform do not move when Rate moves.
- **Contours scale with rate.** A drawn contour is a pure function of *normalized* sample
position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour
length-independent"), so it follows the read head by construction. **The staged
@@ -525,31 +559,28 @@ plugins — lookahead limiters, linear-phase EQs and oversampling processors all
REAPER handles it as a matter of course. The deactivate/reactivate is the *normal* cost of
the flag, and for a typical plugin it is cheap: `setActive` allocates and frees buffers.
**What makes it expensive here is entirely our own design, in one line.**
`ReaSamplerProcessor::setActive` is deliberately destructive in both directions
(`reasampler_processor.cpp:85-109`):
**What made it expensive here was entirely our own design, in one line** — and Γ-W3 removed
that line. `ReaSamplerProcessor::setActive` was deliberately destructive in both directions:
- `setActive(true)` calls `reloadInstrument()` (`:89-97`) — **a bridge read and a full WAV
re-decode**, plus a fresh engine. This is the expensive half, and no part of it is required
by the SDK: it is there because activation was the convenient trigger for a reload, not
because activation implies one.
- `setActive(false)` frees `live_`, `draining_` **and** the graveyard (`:98-107`), so every
sounding voice dies. The comment there explains why that is correct and must not be
softened casually: a surviving `live_` would be displaced into the drain slot on reactivate
and *"resurrect stale sustained voices as ghosts."*
- `setActive(true)` called `reloadInstrument()`**a bridge read and a full WAV re-decode**,
plus a fresh engine. That was the expensive half, and no part of it was required by the SDK:
it was there because activation was the convenient trigger for a reload, not because
activation implies one.
- `setActive(false)` frees `live_`, `draining_` **and** the graveyard, so every sounding voice
dies. That half is correct and must not be softened casually: a surviving `live_` would be
displaced into the drain slot on reactivate and *"resurrect stale sustained voices as
ghosts."*
**So the cost is ours, and it is ours to reduce.** The reduction is **decoupling the reload
from activation** — keeping the decoded `SampleData` alive across a deactivate while still
destroying voice state, which is exactly the shape `rebuildVoiceEngine`'s drain-slot swap
already implements for voice-count edits. **That is a latent improvement with a clear trigger
condition, filed in `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") —
not a reason to abandon dynamic latency, and not scheduled in this phase.**
**The cost was ours, and it has been reduced (Γ-W3 — see §7.11).** The deactivate now parks the
decoded `SampleData` and the reactivate rebuilds only the voice state around it, through the
same drain-slot swap `rebuildVoiceEngine` uses for voice-count edits. An activation cycle costs
no disk read and no decode; an instance with nothing decoded still takes the full reload, which
is where the pre-v10 legacy lift lives.
**The honest cost of the toggle today, stated plainly:** every sounding note stops and the
sample is re-decoded from disk. **Daniel has accepted it** (Γ-F6): *"Toggling the limiter
killing the voices isn't a deal breaker though, the limiter will either be on or off on its
instance, toggling during playback is not a use case."* There is no fallback design and no
measurement gate.
**The honest cost of the toggle, stated plainly:** every sounding note stops. **Daniel has
accepted it** (Γ-F6): *"Toggling the limiter killing the voices isn't a deal breaker though,
the limiter will either be on or off on its instance, toggling during playback is not a use
case."* There is no fallback design and no measurement gate.
#### The standing scar, and why this is nonetheless not the forbidden change
@@ -625,10 +656,9 @@ What is in scope alongside it — and what each is actually for:
in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass`
parameter either.
- **Observe what REAPER does, and record it — as evidence, not as a gate.** Whether notes
cut, whether the re-decode is perceptible, whether transport hiccups, is DAW-observable
only. Record it in Γ-W1-T2's review because it is the trigger-condition evidence for the
`docs/TODO.md` decoupling entry. **No outcome changes the design**; Γ-F6 is closed either
way.
cut and whether transport hiccups is DAW-observable only. The re-decode half of that
question is gone (§7.11), so what remains to observe is the voice cut alone. **No outcome
changes the design**; Γ-F6 is closed either way.
### 3.2 The meter
@@ -683,8 +713,19 @@ reduction is applied.**
Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in").
Derived against that rule — **no new Daniel call**: **rate → reset**, **pitch offset →
reset**, **limiter enabled → reset** (master gain is already on the reset list, so the bake
includes the master stage, so the limiter's effect is in the audio).
reset**, **limiter enabled → reset**.
**The limiter clause's original reasoning was false, and the code was changed to make its
conclusion true.** It read "master gain is already on the reset list, so the bake includes
the master stage, so the limiter's effect is in the audio" — but the bake printed a flat
gain multiply and nothing else; the limiter ran in the processor's block, off the bake path,
so a capture baked with it engaged came back unlimited and resetting the enable was
resetting a control whose effect was NOT in the file. Daniel ruled the goal rather than the
premise: `renderBake` now prints the whole master stage, gain then limiter, so the
classification stands on the rule it always claimed to. The lookahead is compensated inside
the render, and a bypassed bake is the pre-limiter render frame for frame —
`src/core/instrument/bake/CLAUDE.md` owns both, plus the double-limiting boundary a baked
capture inherits.
**This is now a CORRECTION, not a sequencing note.** The original plan required Phase Γ to
land before Ξ-W2 so the bake's reset list would be complete on the day it shipped. **That
@@ -1042,13 +1083,20 @@ being a bare orphan rectangle and becomes the same kind of object as every other
| Mark | Ink | Cap | Line | Label |
|---|---|---|---|---|
| **Start** | `accent/primary` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line |
| **Start** | `overlay/trace` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line |
| **Loop start** | `accent/secondary` | **L-cap opening right** | solid | `LOOP`, right of the line |
| **Loop end** | `accent/secondary` | **L-cap opening left** | solid | `END`, left of the line |
| **Crossfade** | `accent/secondary`, reduced alpha | **ramp cap** — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape | **dashed** — a soft boundary, not a hard one | `XFADE`, left of the line |
Start is the only `accent/primary` mark in the band, because it is the only one that is
always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as `[ … ]`
Start draws in `overlay/trace`, not `accent/primary`: `accent/primary` **is** the waveform
fill, so a primary START would measure 1:1 against the material it marks. `overlay/trace`
measures 3.071:1 against the fill and 3.065:1 against `bg/base`, clearing the 3:1 non-text
floor on both — provably optimal, since `core/ui/CLAUDE.md`'s two-neighbour rule derives
`sqrt(9.41) ≈ 3.07` as the ceiling any single value can hold against both neighbours at
once. Start is still the only mark always in effect (both Gate and Trigger), but that is no
longer what its ink says, now that `overlay/trace` is shared with the envelope trace: the
distinction is carried by shape instead — a straight full-height column under a solid
triangle cap, never a curve. The loop pair's opposed L-caps read as `[ … ]`
without needing to be explained. All four caps use primitives already in the kit
(axis-aligned fills, AA-restroked triangles per `visual-design-language.md` §8).
@@ -1088,17 +1136,23 @@ cosmetic gain.
> already-accepted failure worse.** The edge wedge leaves the loop fill's peak alpha at
> 0.20 exactly as today, so the pair is untouched.
- **The ingredient draws as a ghost.** `[loopStart crossfade, loopStart)` — the material
actually being mixed in — draws the **mirror** wedge (growing right-to-left, peaking at
`loopStart`) at half alpha, outside the loop fill. It carries no handle. **At rest it is a
hairline dashed outline; it fills in on hover or drag of the crossfade handle** — a hover
state in the sense §3.3 of the visual language means, revealing the relationship only when
the user is asking about it.
actually being mixed in — draws the **same ramp** the audible wedge draws (growing
left-to-right, peaking at `loopStart`) at half alpha, outside the loop fill — not a
mirror of it: the incoming tap's weight at ingredient frame `loopStart crossfade + k` is
the same `crossfadeWeight` as audible frame `loopEnd crossfade + k`, so both spans carry
the identical ramp, which is exactly why one `crossfadeWedgeHeight` function draws both.
It carries no handle. **At rest it is a hairline dashed outline; it fills in on hover or
drag of the crossfade handle** — a hover state in the sense §3.3 of the visual language
means, revealing the relationship only when the user is asking about it.
- **This makes the clamp self-explanatory.** The hard clamp is
`crossfade ≤ min(start, loopLength)` (`loop_span.h:19`, and its "no material ahead of the
loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn, **the fade stops
growing exactly when the ghost's left edge reaches the START mark or the LOOP mark** — the
user sees the reason instead of hitting an invisible wall. That is the single best payoff
in this design and it costs nothing extra.
`crossfade ≤ min(loopStart, loopLength)` (`loop_span.h:19` — `maxCrossfade(loopStart,
loopEnd loopStart)`; `start` there names `loopStart`, not the START mark — and its "no
material ahead of the loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn,
**each half of the clamp is now visible, on a different mark:** the ghost's left edge
reaches frame 0 — the overlay's own left edge, not a mark — exactly at the `loopStart`
bound, and the audible wedge's left edge reaches the LOOP mark exactly at the `loopLength`
bound. The user sees why the fade stopped growing instead of hitting an invisible wall.
That is the single best payoff in this design and it costs nothing extra.
**(d) The off-state and the Trigger state get words, not just alpha.**
@@ -1143,8 +1197,8 @@ Three reasons for that exact slot:
2. **Browse stays rightmost.** It is navigation, not a mode — moving it would break the
established right-edge reading.
3. **It costs zero window width.** The run is right-anchored and the title slot absorbs it,
so `kEditorMinWidth` does not move and **none of §1.6's 90 px headroom is spent.**
*Constraint:* the title slot must still hold its text at the 1190 floor. If it will not,
so `kEditorMinWidth` does not move and **none of §1.6's 82 px headroom is spent.**
*Constraint:* the title slot must still hold its text at the 1198 floor. If it will not,
the enable's segments narrow — the floor does not move. That is a hard rule, because the
floor is a phase-wide acceptance criterion.
@@ -1309,7 +1363,7 @@ the floor without touching cell metrics, because the **group inventory and its r
assignment** now also drive it. Restate as: *the deck's cell metrics AND its group/row
composition both drive `kEditorMinWidth`; none of the three may move alone.*
**7.5 — `isLiveDeckParam` becomes three-valued.** See §2.3. The exhaustive switch must
**7.5 — `deckParamCommit` becomes three-valued.** See §2.3. The exhaustive switch must
classify the two new `DeckParam`s or fail to compile — which is exactly what it is designed
to do, and which is why the two new parameters are cheap to add *now*.
@@ -1353,13 +1407,27 @@ squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive
directions. **Those four are hygiene against the `kIoChanged` scar (§3.1.1), not a hedge
against the flag itself** — Γ-F6 is ruled and the restart ships.
**7.11 — `setActive` conflates two lifetimes, and dynamic latency is the first feature that
makes a user notice.** Activation currently means both "the audio thread may run" and "the
decoded `SampleData` is (re)built" (`reasampler_processor.cpp:89-97`). Phase Γ does **not**
separate them — Γ-F6 accepts the cost — but the conflation is now a named, filed improvement
(`docs/TODO.md`, "Decouple the instrument reload from VST3 activation") rather than an
unremarked property. **Do not restructure `setActive` inside this phase**; its destructive
shape is deliberate and its reasoning is documented at the call site.
**7.11 — `setActive` conflated two lifetimes; it no longer does (LANDED, Γ-W3).** Activation
used to mean both "the audio thread may run" and "the decoded `SampleData` is (re)built", so
every host-driven cycle paid a bridge read and a full WAV decode. The two are now separate:
`setActive(false)` parks the decoded sample and destroys the voice state, `setActive(true)`
rebuilds the voices around the parked sample through the drain-slot swap `rebuildVoiceEngine`
already used. **This section's earlier instruction — "do not restructure `setActive` inside
this phase" — was superseded by Daniel's ruling that this track does it**; the deactivate's
destruction of voice state is still deliberate (a surviving `live_` would resurrect stale
sustained voices as ghosts) and only the PCM survives. Nothing parked routes the activation
back through the full reload, which is what keeps the pre-v10 legacy lift reachable. Γ-F6 is
untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost —
it is simply a much cheaper one.
**What "cheaper" does NOT mean: skipping the bank fold.** `reloadInstrument` also runs the
recapture sync and the `rsusage_` prune-protection publish, and with no editor open the
activation is the only place either happens (`pollBankSync` runs off the editor's sync tick and
nothing else). Both are a `GetProjExtState` plus a parse — neither disk nor decode — so both run
on the resume path too, and a fold that moves the loaded capture's decode source hands back to
the full reload rather than resuming PCM the bank has superseded. A resume that refreshed the
refs without re-decoding would be the worst of the three: the table would name a recapture's new
file while the voices played the old one.
---
@@ -1378,8 +1446,8 @@ ceiling.
| **Γ-F2** | Limiter lookahead, or zero-latency? | **Lookahead with DYNAMIC reported latency** — zero when off, the lookahead when on, reported to the host's PDC. *Overrides this doc's zero-lookahead recommendation.* | **§3.1.1** (new), §7.10 |
| **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **REVERSED, same day. Ruled first "not in this phase — stays 2.0 s"; then Daniel: _"extend the stage lengths to 10s."_ The ceiling moves 2.0 → 10.0 in Γ-W1-T1.** The reversal's cause is Ruling 1: parameters now ship in-phase, so the ceiling is a one-way door that has to be walked through *before* them. | **§4.3.1** (new), §4.3; `docs/TODO.md` entry discharged |
| **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 |
| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 |
| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` |
| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 82 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 |
| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11; `docs/TODO.md` decoupling entry discharged in Γ-W3 |
| **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow***"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) |
Three of these corrected this doc rather than confirming it, and all three corrections are
@@ -1425,7 +1493,8 @@ reintroduced:
than just counting:
1. **§3.1.1 was rewritten, not annotated.** Its prior framing — dynamic latency as exotic and
expensive — was wrong. Dynamic PDC is routine; the expense is our reload-on-activate.
expensive — was wrong. Dynamic PDC is routine; the expense was our reload-on-activate, and
Γ-W3 removed it (§7.11).
2. **The measurement gate was dropped.** Γ-W1-T2's first deliverable is the limiter, not a
spike. What remains is an *observation* recorded in review as evidence for the deferred
improvement — it gates nothing.
@@ -1435,7 +1504,7 @@ than just counting:
4. **The verification requirements survive unchanged**, because they were always about the
`kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session
`restartComponent`), not about this flag.
5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`.
5. **The reduction was filed with a trigger condition and has since LANDED** (Γ-W3 — §7.11).
### 8.3 Γ-F7 — RULED: signal flow. The parameter order
@@ -1496,9 +1565,10 @@ Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), **fo
T1 pitch-rate-deck ................. item A (params + engine + deck descriptor)
T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry
+ the chrome-row loop enable)
Γ-W3 The reflow, and the bake correction [2 tracks]
Γ-W3 The reflow, and the bake correction [3 tracks]
T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half
T2 bake-reset-amendment ............ the Phase Ξ correction Γ owns (§3.4)
T3 bake-prints-limiter ............. prints the limiter through the bake's master stage (§3.4)
Γ-W4 VST3 parameters [1 track]
T1 vst3-parameter-set .............. Ruling 1 (parameter-automation.md §§6-10)
```
@@ -1510,7 +1580,7 @@ ceiling into W1 (§4.3.1) and turned the Ξ ordering constraint into an owned co
1. **Item B splits: canvas early, arrangement late.** The window floor, the width budget it
derives from, and each group's row membership land in W1-T4 so every other UI track is
drawn, tested and judged at the final 1190 × 680 window. The two-row layout itself stays in
drawn, tested and judged at the final 1198 × 680 window. The two-row layout itself stays in
W3-T1, because it can only be measured once the final PITCH/RATE and MASTER descriptors
exist. The exact seam — what W1-T4 can assert, what it cannot, and what the editor looks
like in between — is in `docs/PLAN.md` at Γ-W1-T4.
@@ -1522,7 +1592,7 @@ The wave boundaries are collision boundaries, not preferences: `deck_values.cpp`
by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; `deck_groups.cpp` by
W1-T4 (the row predicate) then W2-T1 (the descriptor) then W3-T1 (the row consumption);
`voice.cpp` by W1-T5 then W2-T1; and **one params-payload version bump per wave, owned by one
track** (W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) — the
track** (W1-T2 takes v15 for the limiter flag, W2-T1 takes v16 for rate + pitch offset) — the
two new W1 tracks take **no rung at all**, so the ladder is unchanged by the resequencing.
Two shared files are named rather than discovered at merge:
`core/instrument/engine/CMakeLists.txt` inside W1 (T2 | T5) and `editor_session.cpp` inside
+52 -22
View File
@@ -124,7 +124,7 @@ than balancing it — a fork with a dominated option in it is not a fork.
### 3.4 Which controls can be parameters at all — three classes
The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` /
The good news: **this analysis is already done once, in one place.** `deckParamCommit` /
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild,"
which is the same question automation asks. Phase Γ widens it from two states to three
(§3.5). The parameter work should widen the *same* decision point again rather than start a
@@ -202,8 +202,8 @@ The consequence for this doc is concrete and it is a **subtraction from the para
> latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
> has to deactivate and reactivate the plug-in"*
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees
> every sounding voice and a reactivate re-decodes the WAV. **An automation lane toggling
> that parameter would deactivate the plugin on every flip.**
> every sounding voice. **An automation lane toggling that parameter would deactivate the
> plugin on every flip.**
Two corollaries the parameter work must carry rather than rediscover:
@@ -211,7 +211,7 @@ Two corollaries the parameter work must carry rather than rediscover:
binding it to `kIsBypass` would hand the host a control that restarts the component.
- **Latency reporting must be derived from persisted state, not from a transient.** The SDK
states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and
this plugin's `setActive(false)` frees essentially everything. Whatever holds the limiter
this plugin's `setActive(false)` destroys the whole voice state. Whatever holds the limiter
flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in
@@ -221,12 +221,13 @@ There is no constant-reported-latency fallback — that option is closed, not sh
**this section does not shrink to a footnote and the limiter enable does not become
automatable.** Plan against the not-automatable classification; it is settled.
**One future condition could reopen it, and it is worth knowing about.** The restart is only
expensive because *this plugin's* `setActive(true)` re-decodes the WAV — not because the SDK
requires it. `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") files that
reduction, and **"the limiter enable is wanted automatable" is one of its named trigger
conditions.** If the parameter work genuinely needs that lane, the answer is to do the
decoupling first, not to re-litigate the classification.
**The decoupling that was filed against this section has LANDED (Γ-W3), and it changes the
cost but not the classification.** `setActive(true)` no longer re-decodes the WAV: the decoded
sample now survives a deactivate and only the voice state is rebuilt
(`instrument-control-surface.md` §7.11). So a flip costs a voice rebuild rather than a disk
read plus a decode — but **the deactivate still frees every sounding voice**, which is the
ground the not-automatable classification actually rests on. Plan against not-automatable; if
the parameter work wants that lane, the question to answer is the voice cut, not the decode.
---
@@ -334,11 +335,31 @@ invariant Θ-W1-T1 was run to establish.
> outranks anything the plugin sets, because the host replays it. That is inherent to
> automation and is not a defect to design away — but it has one sharp consequence for the
> resample bake, and that is §9.
>
> **Superseded by the paragraph immediately below.** "Outranks anything the plugin sets" reads
> as unbounded; the bounded formulation there — outranks only until the model has caught up,
> never a later restore/reset/knob move — is the correct one and the one `shell/instrument/
> CLAUDE.md`'s Authority section and `core/instrument/param/param_merge` implement. An unbounded
> hold was tried and is the specific defect this history keeps.
**[verify] at the track, before wiring:** that REAPER calls `setState` (not
`setComponentState`) on a single-component plug-in, and the ordering of `setState` against
the first `IParameterChanges` block after a project load. Verify against the vendored SDK
and in the DAW — do not build on the paragraph above without it.
**SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here
bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as
the GUI-update channel (*"should update the according GUI element(s) only"*,
`ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the
SDK's own `SingleComponentEffect` sample services BOTH
(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering
half dissolves with it, but only because the hold is BOUNDED: an automation point held by the
audio thread is re-applied over every merge until the UI thread folds it into the model, so a
lane that is genuinely driving outranks the restore whichever way round the two arrive, while a
lane that sent one point and had it folded does not. That is the authority rule read correctly —
and note it is reasoning from the host's replay behaviour, not a header quote: the SDK does not
state it. An unbounded hold makes the ordering claim true by making every later writer
permanently deaf, which is not the same property. `shell/instrument/CLAUDE.md`'s Authority
section is the model, and `core/instrument/param/param_merge` is where it is enforced.
**The audio thread cannot run the model path**
(`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches
the live block in place through one pure RT-safe function whose routing is pinned by an
exhaustive equivalence test against the model path.
### 6.2 The ID space: hand-assigned constants in one frozen table
@@ -499,7 +520,7 @@ will eventually propose "fixing" that. The answer is that the two *cannot* both
forever, and only one of the two axes holds still:
> **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor
> 840 → 980, and Γ-W3-T1 takes it to 1190 and re-rows every group into two categorical rows
> 840 → 980, and Γ-W3-T1 takes it to 1198 and re-rows every group into two categorical rows
> with a double-height MASTER. Within-row order is decided by *width fitting*, not by meaning.
> **Binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two
> drift apart** — and after the first drift the order is neither logical *nor* matching, which
@@ -825,7 +846,7 @@ already names that door from the other side.
> **A control is an exposed VST3 parameter if and only if its commit class is `Live` or
> `NoteOnLatched`.** Everything else is omitted from the parameter list entirely.
That makes `isLiveDeckParam` / `liveCommitFor` — already *"THE home for why each excluded
That makes `deckParamCommit` / `liveCommitFor` — already *"THE home for why each excluded
control is excluded"* — the single source for the parameter list too, which is the standing
rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE
place"*) applied once more rather than a second table opened beside it.
@@ -890,17 +911,26 @@ blob, which is exactly what §6.1's split is for.
set **for the note-on-latch reason** currently route through the reload tier, and the new
state fits them exactly:
- **Key-track**`isLiveDeckParam`'s header already says it *"feed[s] values a voice
- **Key-track**`deckParamCommit`'s header already says it *"feed[s] values a voice
latches at note-on by design (the pitch ratio…), so live delivery would retune… a note
already struck."* That sentence describes `NoteOnLatched`, not `Reload`.
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
Same shape.
**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the
predicate's own stated semantics — and it is what makes them automatable, since today they
would re-decode a WAV per automation point. **If either promotion is refused, that control
simply drops out of the parameter list.** The list follows the predicate; the predicate is
never bent to fill the list.
**RULED (Daniel, 2026-08-02): promote both.** Ids 1000 and 1450 issue; the count is 44 of 44.
The promotion aligns the routing with the predicate's own stated semantics — and it is what
makes them automatable, since otherwise they would re-decode a WAV per automation point.
**It was not the predicate-only change this section implied.** Key-track lives on
`InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000
would have no-oped in both directions with nothing failing to compile; both controls also had to
reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the
two latched values as arguments beside the rate. `param::valueHomeFor` is what makes the next
promotion of this shape a test failure instead of a silence, and it earns that claim in three
places rather than one: `test_param_live` asserts every exposed control HAS a home and that the
instance-scalar set has exactly two members, and the shell's own read and write paths
(`modelParamNormalized`, `writeDeckParamToModel`) now BRANCH on it rather than on a hardcoded
control id — so a third instance scalar cannot appear without failing that count.
**Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost;
that paragraph is the first thing to read if it is ever proposed.
+3 -3
View File
@@ -54,9 +54,9 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
# renders its own bake in its own process, which is what keeps the extension's link graph
# free of the voice engine a link edge to it here means the design drifted.
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The
# instrument renders its own bake in its own process, which is what keeps the extension's
# link graph free of the voice engine a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# Bank-package import: the promptless verb plus its action skin. Kept as its own
+59 -23
View File
@@ -1,8 +1,8 @@
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui)
## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
subdirectories:
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
@@ -17,6 +17,11 @@ subdirectories:
- **`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`.
- **`param/`** — what the instrument tells a VST3 host about its automatable parameters,
with no VST3 type in it: the FOREVER-FROZEN id table, the exposed set derived from
`deckParamCommit`, the plain-value layer, and the one formatter per unit category. Sits
ABOVE `ui/` — the list is a function of the commit predicate, never the reverse. See
`param/CLAUDE.md`.
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
interiors, waveform, keyboard strip, capture browser, param controls, envelope
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
@@ -171,9 +176,10 @@ Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other
must live compute, latching the parameters at note on is not acceptable. long term these will be
automatable parameters."* It rejects the precedent, not one instance of it.
- **Which controls are live is ONE decision, recorded in ONE place**`isLiveDeckParam` and
`liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and
why each exclusion is excluded — see there rather than restating the list here.
- **How a control reaches the audio is ONE decision, recorded in ONE place**`deckParamCommit`
and `liveCommitFor` (`ui/deck_groups`), a THREE-state classification (`Live` /
`NoteOnLatched` / `Reload`) whose header is THE home for where each control sits and why —
see there rather than restating the list here.
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
block the shell owns per instance. The member-ordering constraint that enforces it, and why,
are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
@@ -283,19 +289,40 @@ anything for a trigger shape.
- 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. 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.
- `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), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), 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.
- `voice_engine.h` / `voice_engine.cpp``VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher**`writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids.
- **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry exactly** (`periodAlignedJump`, `pitch_shift.h`); do not "simplify" that fallback into an approximation of it.
- `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN:
a decimated cumulative-mean-normalized difference picks the period, the full-rate difference
function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole
number of it. **Runs off the audio thread by link graph** (`period_detect.h` is the one home
for that invariant) — the same shape as the extension's link graph not gaining the voice
engine. Its one caller is the loader (`map/sample_map`'s
`buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period
is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no
rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony,
percussion and a source whose period changes mid-sample all return it, and the shifter's
fixed-window geometry is the documented fallback. **Detection analyses the SUSTAIN LOOP when
the capture carries one long enough to host the full search band** (`periodAnalysisSpan`),
otherwise the whole source: the loop is what a Gate voice asymptotically plays, and a phrase
whose head is pitched differently from its sustain would otherwise disagree its way to none.
A shorter loop analyses the whole source rather than a narrowed band — a narrower span may
never buy itself a higher lowest-findable fundamental.
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob, the processor multiply and the host's `toPlain` so the needle, persisted value, audio multiply and reported dB cannot drift. Math only — the dB label is `param/param_format`'s, so the editor and the host cannot print it two ways.
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked 0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
### `map/`
- `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…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.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
@@ -304,39 +331,48 @@ anything for a trigger shape.
### `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 — 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.
- `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), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. 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 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 — bake Hold cell, bake, preview, velocity knob cell, loop enable, 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).
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes 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), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **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.
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `param_slider`parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `param_taper`THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. **The cell/knob/label sizes and `sample_bands`' editor floor move as a pair** — wider cells need a wider floor width or the deck wraps to a fourth row. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has fourteen pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position maps and their clamps),
`resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
two stage times are comparable at a glance. A display-unit decision only — nothing about the
stored representation changes. Links the header-only `play_seconds`, deliberately not
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over
`param_taper`'s maps), `resetDeckParam` (the double-click reset — the defaults are READ off a
default-constructed `PlaySeconds`, so there is no second table of defaults to drift, and the
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s
`param_format` owns the one formatter per unit category, because the host and the editor must
be its two callers and neither may hold a second implementation. Links the header-only
`play_seconds`, deliberately not
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/12/24/36/48/60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone.
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the 92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `curve_tessellate` — the staged envelope's TRACE, split from `envelope_overlay` on the axis those two already have: that module decides where a node LANDS, this strokes the span BETWEEN two of them. Joins the non-knot vertices with the curve each stage's exponent defines, sampled one point per pixel column, at `start + (end - start) * curveMap(phi)` — the composition `envelopes.h`'s four evaluators use, so a drawn stage and the sound it makes cannot diverge. Node vertices keep their exact integer coordinates (the handles are drawn on them); only the interior samples are sub-pixel. A neutral exponent or a zero level span emits the two endpoints and nothing between, which is the straight stroke drawn before curves existed, vertex for vertex.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
## Gotchas
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned, and it is not linear in seconds either** — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by `param_taper`'s own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse.
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
+2
View File
@@ -2,6 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map)
add_subdirectory(note)
add_subdirectory(ui)
# After ui: the VST3 parameter identity reads the deck's commit predicate and its value binding.
add_subdirectory(param)
# Last: bake composes the three above it.
add_subdirectory(bake)
+36 -7
View File
@@ -21,8 +21,21 @@ decision about what the render made obsolete.
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
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.
- **The whole chain is printed — voice, master gain, then the limiter, in the processor's
own order.** `bake_render.cpp`'s master stage carries the argument. The limiter is printed
only when it is ENGAGED; bypassed, `renderBake` never constructs one and the result is the
pre-limiter render frame for frame. The lookahead is compensated inside the render — the
buffers carry an extra flush window and the capture is read past it — so an engaged bake
under the ceiling is bit-identical to a bypassed one, not the same audio 2 ms late.
- **A printed capture replayed through an engaged limiter is limited TWICE — a NAMED
boundary, not a bug**, and the same shape as the automation-lane limitation below. The
reset is what normally prevents it (`limiterEnabled` is not on the survive list, so a bake
hands the enable back off), and at unity the second pass has nothing to take: every sample
of the printed file is already at or under the ceiling, and the limiter reduces only where
its detector reads ABOVE it — which after a bake means its inter-sample estimate alone. Dial
the enable back on over raised gain, though, and the capture is limited on top of limiting
that is already in its samples. Not detectable from inside the instrument and not corrected
there; the user's remedy is to leave the enable where the bake put it.
- **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
@@ -50,6 +63,19 @@ decision about what the render made obsolete.
- **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one
classification this track made against the ratified rule rather than reading off it.
`bake_reset.cpp` carries the argument at the assignment.
- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list
candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has
no disposition to classify against the ratified reset rule.
- **A host automation lane outranks the reset, and the bake cannot clear it — a NAMED
limitation, not a bug.** Every reset-class value that is also an exposed VST3 parameter is
now notified to the host (the reset writes through `setInstrumentParams`, which is the one
notification funnel), so the host's DISPLAY follows the reset. A lane, however, lives in the
host's project data: if a reset-class parameter carries one, the host replays its curve onto
audio that already has that processing baked in — double processing, and the "sounds as the
dialled instrument sounded just before the click" claim does not hold in that case. There is
no detection available: `IAutomationState` reports the host's automation mode for the whole
plug-in, not per parameter, so both "refuse the bake" and "reset only the un-automated ones"
are unbuildable rather than merely unattractive. The user's remedy is to remove the lane.
## Modules
@@ -58,7 +84,8 @@ decision about what the render made obsolete.
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
`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.
own voice path and then the master stage, summed into an interleaved buffer at the
source's own channel count.
- `bake_reset``BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
both the parameter set and the post-mixer master gain.
@@ -67,10 +94,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 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.
- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that
slows the read makes the head take longer to cross its span, so the window is scaled by the
slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed
alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length
take the product, and the Gate-with-loop branch takes neither.
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
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).
+6 -2
View File
@@ -6,9 +6,11 @@ reasampler_pure_library(bake_plan
LINK PUBLIC note_program sampler_core trigger_seam)
reasampler_test(bake_plan LINK bake_plan)
# limiter beside sampler_core, not through it: the render prints the whole master stage, and
# the limiter runs on the summed output rather than inside a voice.
reasampler_pure_library(bake_render
SOURCES bake_render.cpp
LINK PUBLIC bake_plan sampler_core)
LINK PUBLIC bake_plan sampler_core limiter)
reasampler_test(bake_render LINK bake_render)
# No library of its own: the derived window is a PROPERTY of bake_plan + bake_render
@@ -17,4 +19,6 @@ 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)
# loop_marks is a TEST-only edge: it defines what a neutral loop looks like on the band, so
# the reset's loop assertions read it rather than restating it.
reasampler_test(bake_reset LINK bake_reset loop_marks)
+31 -17
View File
@@ -6,6 +6,7 @@
#include <cmath>
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold)
#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound)
#include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
@@ -28,22 +29,36 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
return true;
}
// 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 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 *
play.pitchVelocityCurve.eval(velocity));
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
//
// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
// feeds the stretcher at it, so either way the source is consumed at that many frames per
// output frame. Taken through the engine's clamp, because that is the value Voice::start
// actually plays.
//
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
// rate alone, which is the only sense in which the two are decoupled there.
//
// A Gate release is ticked per output frame, so neither term touches it.
double playbackStretch(const PlayParams& play, int velocity) {
double down = 0.0;
if (play.pitchEngine == PitchEngine::Varispeed) {
down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
// Taken as a bound rather than exactly, like the velocity term beside it: an upward
// offset only makes the read faster, and every term in this sum is a floor.
down += (std::min)(0.0, play.pitchOffsetSemitones);
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
}
return down;
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
}
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
@@ -78,8 +93,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int 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 stretch = playbackStretch(dialed.play, p.velocity.value());
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0;
+3 -2
View File
@@ -32,7 +32,8 @@ bool bakeWindowNeedsHold(const SampleData& dialed);
// the bake renders at, which is what the engine's frame counts are consumed against):
//
// Trigger — the note IS the play span (note-off is ignored anyway), stretched by the
// deepest downward Varispeed offset.
// slowest read the dialed voice can reach: Rate under BOTH engines, plus the
// deepest downward pitch offset under Varispeed.
// Gate, loop — `hold` is the note length; the end offset is the release.
// Gate, 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.
@@ -44,7 +45,7 @@ bool bakeWindowNeedsHold(const SampleData& dialed);
// 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.
// at, and it feeds the Varispeed half of that stretch as well as the render.
//
// Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` —
// meets the tempo in resolveNote, with the rest of the program's beat-denominated fields.
+40 -12
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include "core/instrument/engine/limiter.h"
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::instrument::bake {
@@ -18,7 +19,8 @@ constexpr std::int64_t kBlockFrames = 512;
} // namespace
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
@@ -36,9 +38,16 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
sample.live = nullptr;
const int channels = sample.channelCount();
// The limiter delays its output by its lookahead, so the buffers carry that many extra
// frames and the window is read that far in — the file is the same frames it would be
// with the limiter bypassed, not the capture shifted late by 2 ms. The extra input is
// SILENCE rather than more rendered audio: the file ends at the window, so a peak past
// it is not in the capture and must not duck the frames that are.
const auto flushFrames = static_cast<std::size_t>(
limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0);
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
std::vector<AudioSample> left(rendered, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
std::vector<AudioSample> left(rendered + flushFrames, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered + flushFrames : 0u, 0.f);
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
@@ -69,20 +78,39 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
pos += chunk;
}
// The whole master stage is printed here rather than left for the processor, in the
// processor's own order — gain, then the limiter — because resetAfterBake hands both
// controls back neutral: a render that only summed voices would return every iteration
// shifted by 1/gain and unlimited, and a gain dialed to silence would come back at full
// level. A flat gain multiply, not the processor's per-sample ramp: the gain is constant
// for the whole render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (AudioSample& s : left) s *= gain;
for (AudioSample& s : right) s *= gain;
if (limiterEnabled) {
engine::Limiter limiter;
// Enabled BEFORE prepare, whose reset snaps to the enable target: that starts the
// render already engaged. Enabling afterwards takes process()'s live-engage path,
// which mutes for the delay-line prime and then fades in — silencing the head of the
// capture. prepare()'s allocation and transcendentals are legal here: the bake runs
// on the UI thread, never in process().
limiter.setEnabled(true);
limiter.prepare(plan.sampleRate);
// One call: kMaxBakeFrames bounds the whole buffer well inside int, and a block
// split would change nothing (the limiter carries its state across calls).
limiter.process(left.data(), channels == 2 ? right.data() : nullptr,
static_cast<int>(left.size()));
}
out.channelCount = channels;
out.sampleRate = plan.sampleRate;
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
const auto lead = static_cast<std::size_t>(plan.leadInFrames) + flushFrames;
const auto total = static_cast<std::size_t>(plan.totalFrames);
out.interleaved.resize(total * static_cast<std::size_t>(channels));
// Printed here rather than left for the processor: resetAfterBake hands master gain
// back to unity, so a render that only summed voices would return every iteration
// shifted by 1/gain, and a gain dialed to silence would come back at full level. A flat
// multiply, not the processor's per-sample ramp: the gain is constant for the whole
// render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (std::size_t f = 0; f < total; ++f) {
out.interleaved[f * channels] = left[lead + f] * gain;
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
out.interleaved[f * channels] = left[lead + f];
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f];
}
return out;
}
+7 -6
View File
@@ -29,11 +29,12 @@ struct BakeAudio {
bool empty() const { return frameCount() == 0; }
};
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
// post-mixer gain the processor applies after the engine; see the gain multiply in
// bake_render.cpp for why it is printed here rather than left to the processor. The result
// is the plan's captured window: the lead-in frames are rendered and dropped. An unplayable
// sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
// Renders `plan` through `sample`'s own voice path and then the master stage the processor
// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see
// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled`
// false yields the pre-limiter render. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled);
} // namespace reasampler::instrument::bake
+2 -1
View File
@@ -12,7 +12,8 @@ namespace reasampler::instrument::bake {
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
// parameter set; it is answered here because renderBake prints it into the file (see
// bake_render.cpp's gain multiply) rather than left to the shell.
// bake_render.cpp's master stage) rather than left to the shell. The limiter needs no field
// of its own: its enable rides the parameter set, and the render prints it too.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
+46 -1
View File
@@ -5,6 +5,12 @@ reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
reasampler_test(pitch_shift LINK pitch_shift)
# Deliberately NOT linked by sampler_core, enforcing period_detect.h's off-audio-thread
# invariant at build time: sampler_core_tests links sampler_core and nothing else, so no TU
# on the render path can name detectPeriod without failing to link.
reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks)
reasampler_test(period_detect LINK period_detect)
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
# depend on the curve without inheriting the editor's layout types.
@@ -32,7 +38,8 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span)
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span
time_stretch)
# Links only sampler_core: linking more would break the plain-data-boundary proof a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
@@ -48,3 +55,41 @@ reasampler_test(live_delivery LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
# Measurement harness for Preserve on low-frequency material: how the splice search's
# reachable relocation interval interacts with a long source period. Written longhand and
# deliberately NOT add_test()'d it sweeps frequencies, windows and spectra and takes ~2m40s
# in Debug, which does not belong in a gate whose other targets run in seconds. It still
# builds with everything else, so it cannot rot into non-compilation. Run it by hand, in
# Release, when the question is what Preserve does to a given frequency.
add_executable(preserve_low_frequency_tests
${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp)
# period_detect beside sampler_core, not through it: the harness plays the role the loader
# does, which is exactly the seam under measurement.
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect)
# Bridges the two structural proofs above (sample_map never links the voice engine;
# sampler_core never links period_detect) for the one case that needs both: a REAL detected
# period reaching a real Preserve render. Its own target rather than extending either.
reasampler_test(period_render_integration LINK sample_map sampler_core)
# The Preserve read's source-feed schedule the TIME half beside pitch_shift's PITCH half.
# Header-only (it sits on the per-sample feed), hence INTERFACE.
add_library(time_stretch INTERFACE)
target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR})
target_link_libraries(time_stretch INTERFACE loop_span)
reasampler_test(time_stretch LINK time_stretch)
# The master bus's two pure halves. Neither links the engine: the limiter runs on the summed
# output, and the ballistics run on what the audio thread published about it.
reasampler_pure_library(limiter SOURCES limiter.cpp)
reasampler_test(limiter LINK limiter)
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
reasampler_test(meter_ballistics LINK meter_ballistics)
# The meter's ACCUMULATE half, beside the ballistics that consume it. Header-only (the folds
# sit on the audio thread's per-block path), hence INTERFACE.
add_library(meter_accumulate INTERFACE)
target_include_directories(meter_accumulate INTERFACE ${REASAMPLER_SRC_DIR})
reasampler_test(meter_accumulate LINK meter_accumulate)
+10 -3
View File
@@ -421,7 +421,12 @@ public:
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
void snapLive(const PitchEnvParams& params) {
//
// Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is
// an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a
// live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact.
void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) {
span_ = spanFrames > 0 ? spanFrames : 0;
params_.peakSemitones = params.peakSemitones;
params_.shape = params.shape;
fit_ = fitAhd(span_, params_.shape);
@@ -430,9 +435,11 @@ public:
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
// level, not a duration).
void applyLive(const PitchEnvParams& params) {
// level, not a duration). A moved span re-fits under the same rule, so a live Pitch move
// reshapes this envelope continuously instead of leaving it on the note-on read rate.
void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) {
const double before = offsetAt();
span_ = spanFrames > 0 ? spanFrames : 0;
const AhdSpan next = fitAhd(span_, params.shape);
pos_ = holdPhase(fit_, next);
params_.peakSemitones = params.peakSemitones;
@@ -1,10 +1,16 @@
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
# The frozen control-position laws are their own target: they are the filter's PARAMETER
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
# that consumer does not take a link edge onto the per-voice kernel the extension's link
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
reasampler_pure_library(filter SOURCES
filter_params.cpp
filter_coeffs.cpp
filter_morph.cpp
voice_filter.cpp)
voice_filter.cpp
LINK PUBLIC filter_params)
# Four test targets along the module's own seams so each asserts one domain. filter_tests
# alone owns the analytic reference and the steady-state gain measurement a forked copy of
@@ -52,6 +52,13 @@ float filterDriveDepthFromNorm(float norm) {
return static_cast<float>(kFilterDriveDepthMax * n * n);
}
float filterNormFromDriveDepth(float depth) {
if (!(depth > 0.0f)) return 0.0f; // also catches NaN
if (depth >= kFilterDriveDepthMax) return 1.0f;
return static_cast<float>(
std::sqrt(static_cast<double>(depth) / static_cast<double>(kFilterDriveDepthMax)));
}
float filterNormFromQ(float q) {
if (!(q > kFilterQMin)) return 0.0f;
if (q >= kFilterQMax) return 1.0f;
@@ -49,4 +49,9 @@ float filterNormFromQ(float q);
// linear rather than merely close.
float filterDriveDepthFromNorm(float norm);
// Exact inverse of filterDriveDepthFromNorm; out-of-range depth clamps to 0 or 1. The analytic
// inverse of a frozen law is not a change to it — it has the standing the two inverses above
// already have.
float filterNormFromDriveDepth(float depth);
} // namespace reasampler::instrument::engine::filter
+227
View File
@@ -0,0 +1,227 @@
// limiter.cpp — see limiter.h.
#include "core/instrument/engine/limiter.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::engine {
namespace {
constexpr int kProtoLen = kLimiterOversample * kLimiterOsTaps + 1; // 33: odd, so phase 0 is exact
double sincPi(double x) {
if (x == 0.0) return 1.0;
const double a = 3.14159265358979323846 * x;
return std::sin(a) / a;
}
} // namespace
double limiterCeilingLinear() { return std::pow(10.0, kLimiterCeilingDbTp / 20.0); }
int limiterLookaheadSamples(double sampleRate) {
if (!(sampleRate > 0.0)) return 0;
const int n = static_cast<int>(kLimiterLookaheadSeconds * sampleRate + 0.5);
// One sample above the detector's group delay is the floor: the smoothing window must have
// at least one entry of its own for the no-overshoot bound to say anything.
return n > kLimiterOsDelay ? n : kLimiterOsDelay + 1;
}
void Limiter::prepare(double sampleRate) {
latency_ = limiterLookaheadSamples(sampleRate);
if (latency_ <= 0) latency_ = kLimiterOsDelay + 1;
window_ = latency_ - kLimiterOsDelay + 1;
ceiling_ = static_cast<float>(limiterCeilingLinear());
const double rate = sampleRate > 0.0 ? sampleRate : 48000.0;
releaseCoeff_ = static_cast<float>(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate)));
switchStep_ = static_cast<float>(1.0 / (kLimiterMuteSeconds * rate));
// Windowed-sinc polyphase interpolator, built here because it costs transcendentals.
// Phase 0's taps all land on sinc zeros except the centre, so it is an exact delay and is
// read straight out of the history instead of being convolved.
for (int p = 0; p < kLimiterOversample; ++p) {
for (int k = 0; k < kLimiterOsTaps; ++k) {
const int i = kLimiterOversample * k + p;
const double centred = static_cast<double>(i) - (kProtoLen - 1) / 2.0;
const double hann =
0.5 - 0.5 * std::cos(2.0 * 3.14159265358979323846 * i / (kProtoLen - 1));
osTaps_[p][k] = static_cast<float>(sincPi(centred / kLimiterOversample) * hann);
}
}
delayL_.assign(static_cast<std::size_t>(latency_), 0.f);
delayR_.assign(static_cast<std::size_t>(latency_), 0.f);
wedgeVal_.assign(static_cast<std::size_t>(window_), 1.f);
wedgeIdx_.assign(static_cast<std::size_t>(window_), 0);
avgRing_.assign(static_cast<std::size_t>(window_), 1.f);
reset();
}
void Limiter::clearState() {
std::fill(delayL_.begin(), delayL_.end(), 0.f);
std::fill(delayR_.begin(), delayR_.end(), 0.f);
delayPos_ = 0;
for (int i = 0; i < kLimiterOsTaps; ++i) { histL_[i] = 0.f; histR_[i] = 0.f; }
histPos_ = 0;
wedgeHead_ = 0;
wedgeCount_ = 0;
pushIndex_ = 0;
std::fill(avgRing_.begin(), avgRing_.end(), 1.f);
avgSum_ = static_cast<double>(window_);
avgPos_ = 0;
releaseGain_ = 1.f;
}
void Limiter::reset() {
clearState();
active_ = target_.load(std::memory_order_relaxed);
switchGain_ = active_ ? 1.f : 0.f;
primeRemaining_ = 0;
}
void Limiter::setEnabled(bool on) { target_.store(on, std::memory_order_relaxed); }
float Limiter::detectTruePeak(float xl, float xr, bool stereo) {
histPos_ = (histPos_ + 1) & (kLimiterOsTaps - 1);
histL_[histPos_] = xl;
if (stereo) histR_[histPos_] = xr;
// Phase 0 is the exact delay, so the sample under test is read, not convolved.
const int base = (histPos_ - kLimiterOsDelay + kLimiterOsTaps) & (kLimiterOsTaps - 1);
float peak = std::fabs(histL_[base]);
if (stereo) {
const float r0 = std::fabs(histR_[base]);
if (r0 > peak) peak = r0;
}
for (int p = 1; p < kLimiterOversample; ++p) {
float accL = 0.f, accR = 0.f;
for (int k = 0; k < kLimiterOsTaps; ++k) {
const int idx = (histPos_ - k + kLimiterOsTaps) & (kLimiterOsTaps - 1);
accL += osTaps_[p][k] * histL_[idx];
if (stereo) accR += osTaps_[p][k] * histR_[idx];
}
const float al = std::fabs(accL);
if (al > peak) peak = al;
if (stereo) {
const float ar = std::fabs(accR);
if (ar > peak) peak = ar;
}
}
return peak;
}
float Limiter::smoothGain(float target) {
// Sliding minimum over `window_` via a monotonic wedge. Expiring the front BEFORE the push
// is what bounds the wedge to `window_` entries — pushing first can lap the ring. Wraps by
// compare-and-subtract, matching delayPos_/avgPos_: window_ is not a power of two, so `%`
// would not strength-reduce on this per-sample path.
while (wedgeCount_ > 0 &&
wedgeIdx_[static_cast<std::size_t>(wedgeHead_)] <= pushIndex_ - window_) {
wedgeHead_ = (wedgeHead_ + 1 == window_) ? 0 : wedgeHead_ + 1;
--wedgeCount_;
}
while (wedgeCount_ > 0) {
const int backSum = wedgeHead_ + wedgeCount_ - 1;
const int back = (backSum >= window_) ? backSum - window_ : backSum;
if (wedgeVal_[static_cast<std::size_t>(back)] < target) break;
--wedgeCount_;
}
const int slotSum = wedgeHead_ + wedgeCount_;
const int slot = (slotSum >= window_) ? slotSum - window_ : slotSum;
wedgeVal_[static_cast<std::size_t>(slot)] = target;
wedgeIdx_[static_cast<std::size_t>(slot)] = pushIndex_;
++wedgeCount_;
++pushIndex_;
const float windowMin = wedgeVal_[static_cast<std::size_t>(wedgeHead_)];
// Moving average of the same width over those minima.
avgSum_ += static_cast<double>(windowMin) - static_cast<double>(avgRing_[static_cast<std::size_t>(avgPos_)]);
avgRing_[static_cast<std::size_t>(avgPos_)] = windowMin;
avgPos_ = (avgPos_ + 1 == window_) ? 0 : avgPos_ + 1;
float smoothed = static_cast<float>(avgSum_ / window_);
// Never above unity — the structural form of "no makeup gain, ever", and what makes the
// at-rest gain land on EXACTLY 1.0f after the running sum has been added to and subtracted
// from for hours.
if (!(smoothed < 1.f)) smoothed = 1.f;
// Release: falls with the smoother, rises no faster than the one-pole. Staying at or below
// `smoothed` is what preserves the no-overshoot bound.
if (smoothed < releaseGain_) releaseGain_ = smoothed;
else releaseGain_ += (smoothed - releaseGain_) * releaseCoeff_;
return releaseGain_;
}
float Limiter::process(float* left, float* right, int frames) {
if (!left || frames <= 0 || latency_ <= 0) return 1.f;
const bool want = target_.load(std::memory_order_relaxed);
if (!want && !active_) return 1.f; // settled bypass: not one sample read or written
if (want && !active_) {
// A live engage. The dry path leaves circuit AT THIS SAMPLE rather than fading out:
// fading it would emit unlimited signal at a partial weight, which is a peak over the
// ceiling. Silence covers the delay line's prime, then the fade-in rides the limited
// path, every sample of which is already under the ceiling.
clearState();
active_ = true;
switchGain_ = 0.f;
primeRemaining_ = latency_;
}
const bool stereo = (right != nullptr);
float blockMin = 1.f;
for (int i = 0; i < frames; ++i) {
const float dryL = left[i];
const float dryR = stereo ? right[i] : 0.f;
const float peak = detectTruePeak(dryL, dryR, stereo);
const float targetGain = peak > ceiling_ ? ceiling_ / peak : 1.f;
const float gain = smoothGain(targetGain);
const std::size_t slot = static_cast<std::size_t>(delayPos_);
const float wetL = delayL_[slot] * gain;
const float wetR = stereo ? delayR_[slot] * gain : 0.f;
delayL_[slot] = dryL;
if (stereo) delayR_[slot] = dryR;
delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1;
// Settled engaged is a branch rather than `wet * 1.0f` so it is bit-exact.
const float s = switchGain_;
if (s >= 1.f) {
left[i] = wetL;
if (stereo) right[i] = wetR;
} else if (s > 0.f) {
left[i] = wetL * s;
if (stereo) right[i] = wetR * s;
} else {
left[i] = 0.f;
if (stereo) right[i] = 0.f;
}
// `gain` is the limiter's own reduction, computed from the real input this sample
// whether or not the mute is currently scaling it toward silence — publishing it
// unscaled is what lets the meter show "really limiting" and not "just muting".
if (gain < blockMin) blockMin = gain;
// A disengage is tested FIRST so a toggle-off arriving mid-engage abandons the prime
// instead of waiting it out in silence.
if (!want) {
switchGain_ = s - switchStep_;
if (switchGain_ <= 0.f) {
// The disengage completes HERE, sample-accurately: the delay leaves circuit and
// the rest of the block is the dry buffer, untouched. Resuming from silence is
// the accepted discontinuity; fading the dry path back in instead would put
// unlimited signal at a partial weight, which is the leak the ceiling forbids.
switchGain_ = 0.f;
active_ = false;
break;
}
} else if (primeRemaining_ > 0) {
--primeRemaining_;
} else if (s < 1.f) {
switchGain_ = (s + switchStep_ >= 1.f) ? 1.f : s + switchStep_;
}
}
return blockMin;
}
} // namespace reasampler::instrument::engine
+129
View File
@@ -0,0 +1,129 @@
// limiter.h — the master bus's lookahead brickwall limiter: true-peak sidechain detection,
// stereo-linked gain, and NO makeup gain of any kind. RT: process() allocates nothing, takes
// no lock and evaluates no transcendental; prepare() owns every allocation and every exp/pow.
// Bypassed and settled, process() returns without touching a sample — that untouched buffer
// is what makes the master bus byte-identical to the bare ramped multiply with the limiter off.
#pragma once
#include <atomic>
#include <cstdint>
#include <vector>
namespace reasampler::instrument::engine {
// The BAKED ceiling. A safety device with no configurable controls, so this is not a
// parameter. dBTP is a TRUE-peak target, which is why the detector oversamples and the
// signal path never does — though the bound is on the detector's 4x-oversampled ESTIMATE,
// not infinite-resolution true peak (normal for any practical TP limiter, and part of why
// this ceiling sits at -0.3 rather than 0).
inline constexpr double kLimiterCeilingDbTp = -0.3;
// The total delay the limiter imposes while engaged, and therefore the plugin's whole reported
// PDC latency. The detector's own group delay is inside this budget, not on top of it.
inline constexpr double kLimiterLookaheadSeconds = 0.002;
// Gain recovery. The min-then-average smoother releases in one lookahead window on its own,
// which distorts low frequencies; this one-pole only ever slows the RISE, so the smoother's
// no-overshoot bound survives it unchanged.
inline constexpr double kLimiterReleaseSeconds = 0.100;
// The transition mute. Long enough that the fade is not itself an edge and that it dwarfs the
// 2 ms delay-line prime it covers; short enough that the whole muted window (prime + fade) is
// ~12 ms rather than a gap. Linear in amplitude, not equal-power: this fades ONE leg to
// silence, it does not cross two.
inline constexpr double kLimiterMuteSeconds = 0.010;
// 4x true-peak oversampling (ITU-R BS.1770's floor at 48 kHz) over an 8-tap-per-phase
// polyphase interpolator. The 33-tap prototype's centre tap makes phase 0 an exact 4-sample
// delay, and that delay is the detector's group delay.
inline constexpr int kLimiterOversample = 4;
inline constexpr int kLimiterOsTaps = 8;
inline constexpr int kLimiterOsDelay = 4;
// kLimiterCeilingDbTp as a linear magnitude.
double limiterCeilingLinear();
// The delay the limiter imposes while engaged, in samples at `sampleRate` — what the plugin
// reports to the host's PDC. 0 at a non-positive rate; never below the detector's own delay.
int limiterLookaheadSamples(double sampleRate);
// The master-bus limiter. One instance per plugin instance; prepare() before the first block.
//
// The gain law is a sliding MINIMUM of the per-sample target gain over the lookahead window,
// then a MOVING AVERAGE of the same width. Every term of that average is a minimum whose own
// window contains the sample being gained, so the smoothed gain is <= the target gain at every
// sample by construction — the ceiling is held structurally rather than by a tuned attack.
//
// SWITCHING IS A MUTE, NOT A BLEND. Unlimited signal is emitted at weight 1 (settled bypass,
// which is the untouched buffer) or at weight 0, never in between — a fraction of an unlimited
// signal is a peak above the ceiling, which is exactly the leak this design forbids. So the
// FADE always rides the limited path (any weight of it is already under the ceiling, since the
// mute only scales down) and the HARD EDGE always lands on the bypassed side, against silence:
// engaging mutes at once, holds while the delay line primes, then fades the limited path in;
// disengaging fades the limited path out and resumes the dry buffer from silence. That
// discontinuity is accepted; a spike is not.
class Limiter {
public:
// Sizes the delay line, the detector and the smoothers, and snaps to the current enable
// state. Allocates and evaluates transcendentals: main/UI thread only, never in process().
void prepare(double sampleRate);
// Clears the delay line and the detector and snaps to the current enable state, skipping
// the transition mute — an activation has nothing sounding to be continuous with.
// Main/UI thread only (the host guarantees process() is stopped at both call sites).
void reset();
// The enable target. Set on the UI thread, observed by process() at block start.
void setEnabled(bool on);
bool enabled() const { return target_.load(std::memory_order_relaxed); }
// Applies the limiter in place over `frames` of `left` (and `right`, which may be null for
// a mono buffer). Returns the SMALLEST gain the LIMITER ITSELF computed this block —
// smoothGain's output against the real input, at every sample including a muted one — NOT
// scaled by the transition mute. The mute is a switch, not limiting: scaling by it would
// report 0.0 (full reduction) on every toggle regardless of program content, which is a
// meter defect, not a fact about the bus. 1.0 means no detected peak exceeded the ceiling,
// whether settled bypassed or mid-mute over quiet material.
float process(float* left, float* right, int frames);
private:
void clearState();
// The detector's true-peak estimate for the sample kLimiterOsDelay back, given the newest
// input frame. Advances the FIR history.
float detectTruePeak(float xl, float xr, bool stereo);
// Pushes one target gain through the sliding minimum and the moving average.
float smoothGain(float target);
std::atomic<bool> target_{false};
// --- prepared geometry ---
int latency_ = 0; // total delay; also the delay ring's length
int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1
float ceiling_ = 1.f;
float releaseCoeff_ = 1.f;
float switchStep_ = 1.f;
float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay)
// --- audio-thread state ---
std::vector<float> delayL_, delayR_;
int delayPos_ = 0;
float histL_[kLimiterOsTaps] = {};
float histR_[kLimiterOsTaps] = {};
int histPos_ = 0;
// Monotonic wedge over the target gain: values ascending from the front, so the front is
// the window minimum. Amortized O(1) per sample, bounded by 2 ops per push over a block.
std::vector<float> wedgeVal_;
std::vector<std::int64_t> wedgeIdx_;
int wedgeHead_ = 0, wedgeCount_ = 0;
std::int64_t pushIndex_ = 0;
std::vector<float> avgRing_;
double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever
int avgPos_ = 0;
float releaseGain_ = 1.f;
bool active_ = false; // the limited path is in circuit (engaged, or still fading out)
float switchGain_ = 0.f; // the transition mute; only ever scales the LIMITED path
int primeRemaining_ = 0; // samples held at silence while the delay line fills
};
} // namespace reasampler::instrument::engine
+55 -2
View File
@@ -5,8 +5,17 @@
namespace reasampler::instrument::engine {
LiveValues foldLive(const PlayParams& params) {
LiveValues v;
LiveValues foldLive(const PlayParams& params, double keyTrack) {
// Value-initialized, so the padding is determinate too. Nothing reads it — the block's
// equality is field-wise for exactly that reason — but this is the one construction site
// every publisher goes through, and an object with indeterminate bytes travelling under a
// seqlock is a hazard worth not having. Off the audio thread; the memset costs nothing here.
LiveValues v{};
v.keyTrack = keyTrack;
// Folded here, not at the voice: Voice::start reads the block's value directly, so the
// spline rule has to be applied on the way in or the two would answer differently.
v.splineActive = splineActive(params);
v.lengthFraction = effectiveLengthFraction(params);
v.filterSettings = params.filter.settings;
v.filterModAmount = params.filter.modAmount;
v.filterVelAmount = params.filter.velAmount;
@@ -16,9 +25,53 @@ LiveValues foldLive(const PlayParams& params) {
v.adsr = params.adsr;
v.ampAhd = params.trigAhd;
v.pitchEnv = params.pitchEnv;
v.playRate = params.playRate;
v.pitchOffsetSemitones = params.pitchOffsetSemitones;
return v;
}
namespace {
bool sameAdsr(const AdsrParams& a, const AdsrParams& b) {
return a.attackFrames == b.attackFrames && a.holdFrames == b.holdFrames &&
a.decayFrames == b.decayFrames && a.sustainLevel == b.sustainLevel &&
a.releaseFrames == b.releaseFrames && a.attackCurve == b.attackCurve &&
a.decayCurve == b.decayCurve && a.releaseCurve == b.releaseCurve;
}
bool sameAhd(const AhdParams& a, const AhdParams& b) {
return a.attackFrames == b.attackFrames && a.decayFrames == b.decayFrames &&
a.holdFraction == b.holdFraction && a.attackCurve == b.attackCurve &&
a.decayCurve == b.decayCurve;
}
bool sameFilterSettings(const filter::FilterSettings& a, const filter::FilterSettings& b) {
return a.cutoffNorm == b.cutoffNorm && a.resonanceNorm == b.resonanceNorm &&
a.morphNorm == b.morphNorm && a.driveNorm == b.driveNorm &&
a.morphLaw == b.morphLaw;
}
} // namespace
bool operator==(const LiveValues& a, const LiveValues& b) {
return sameFilterSettings(a.filterSettings, b.filterSettings) &&
a.filterModAmount == b.filterModAmount &&
a.filterVelAmount == b.filterVelAmount &&
a.filterKeyTrack == b.filterKeyTrack &&
sameAdsr(a.filterEnv, b.filterEnv) &&
sameAhd(a.filterAhd, b.filterAhd) &&
sameAdsr(a.adsr, b.adsr) &&
sameAhd(a.ampAhd, b.ampAhd) &&
a.pitchEnv.enabled == b.pitchEnv.enabled &&
a.pitchEnv.peakSemitones == b.pitchEnv.peakSemitones &&
sameAhd(a.pitchEnv.shape, b.pitchEnv.shape) &&
a.playRate == b.playRate &&
a.pitchOffsetSemitones == b.pitchOffsetSemitones &&
a.keyTrack == b.keyTrack &&
a.lengthFraction == b.lengthFraction &&
a.splineActive == b.splineActive;
}
double liveRampStep(double sampleRate) {
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
return 1.0 / (kLiveRampSeconds * sampleRate);
+48 -2
View File
@@ -44,15 +44,57 @@ struct LiveValues {
AdsrParams adsr{};
AhdParams ampAhd{};
PitchEnvParams pitchEnv{};
// The block's THIRD commit class, and the reason this comment is here rather than at the
// predicate: playRate is published like any live control but read ONLY at note-on, by
// Voice::start via VoiceEngine::startVoice — never by applyLive on a sounding voice. A live
// rate would mean re-folding an already-resolved sustain loop and re-mapping a contour
// mid-note, both of which are note-on folds. pitchOffsetSemitones has no such tie and is
// ordinarily live.
double playRate = 1.0;
double pitchOffsetSemitones = 0.0;
// Two more members of playRate's note-on-latched class, here for the same reason it is:
// both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live
// delivery would retune or re-span a note already struck. Voice::start receives them as
// arguments; applyLive never touches either.
double keyTrack = kKeyTrackDefault;
// ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function
// over the whole sample, so the stored knob is inert while one is active and the block must
// carry what the voice will actually play, not the stored value.
double lengthFraction = 1.0;
// The drawn-EG state the fold above reads. A mode flip travels by reload like the contours
// themselves, so this is not a control; it rides here only so a block-boundary write of
// Trigger length (a host automation point) can apply the SAME fold rather than un-doing it.
bool splineActive = false;
};
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
static_assert(std::is_trivially_copyable_v<LiveValues>,
"the live block is copied under a seqlock — it must stay a plain value");
// A SIZE-CHANGING edit only: padding can absorb a member added beside an existing one (a bool
// beside splineActive, a fifth FilterSettings float) without moving this literal at all, so this
// assert is NOT the guard against a forgotten operator== field —
// testEveryFieldOfLiveValuesIsCompared (test_live_params.cpp) is that guard, poisoning one leaf
// at a time. This assert only catches an edit that changes sizeof(LiveValues) itself. Confirmed
// 352 bytes, MSVC 19.44 x64, Release (`SizeProbe<sizeof(LiveValues)>`, an incomplete-template
// size probe whose error message reports the value). Bump the literal AND operator== together.
static_assert(sizeof(LiveValues) == 352,
"a member was added or removed — extend operator== in live_params.cpp to match");
// FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries
// padding, and nothing gives that padding a determinate value across a copy: NRVO is optional
// and the implicit copy/move is specified member-wise, so two blocks folded from the same
// parameter set are NOT reliably byte-equal. A byte compare therefore reports differences that
// do not exist — which is exactly what it did before this existed. Listed member by member, so a
// member added to the block above must be added here as well; this sits directly beneath the
// struct for that reason.
bool operator==(const LiveValues& a, const LiveValues& b);
inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a == b); }
// The ONE derivation of the live block from the parameter set. Every publisher goes through
// here so there is a single site to keep in step with PlayParams.
LiveValues foldLive(const PlayParams& params);
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
// because it belongs to the capture/instrument scalar beside the play bundle, not to
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
// and an even generation; the reader copies the block and re-checks the generation, retrying
@@ -88,6 +130,10 @@ public:
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
+7 -12
View File
@@ -6,7 +6,6 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
namespace reasampler::instrument::engine {
@@ -18,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
double masterGainDbFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
// UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's
// reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a
// hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the
// half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0.
// PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15.
// Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables
// contraction here breaks the exactness test in test_param_units, which is where it surfaces.
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
}
@@ -37,15 +43,4 @@ double masterGainNormFromLinear(double linear) {
return masterGainNormFromDb(20.0 * std::log10(linear));
}
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
if (!buf || len == 0) return;
norm = clamp01(norm);
if (norm <= 0.0) {
std::snprintf(buf, len, "-inf");
return;
}
const double db = masterGainDbFromNorm(norm);
std::snprintf(buf, len, "%+.1fdB", db);
}
} // namespace reasampler::instrument::engine
-6
View File
@@ -7,8 +7,6 @@
#pragma once
#include <cstddef>
namespace reasampler::instrument::engine {
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
@@ -31,8 +29,4 @@ double masterGainLinearFromNorm(double norm);
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
double masterGainNormFromLinear(double linear);
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// Writes at most `len` bytes including the terminator.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine
@@ -0,0 +1,69 @@
// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold
// into the two windows the UI drains, and the drain that starts the next window. The ballistics
// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio
// thread's per-block path. The folds are templated on the accumulator ONLY so the
// drain-inside-the-fold interleave below can be pinned deterministically instead of raced for.
#pragma once
#include <atomic>
namespace reasampler::instrument::engine {
// A lock-backed std::atomic<float> would put a mutex on the audio thread; assert the freedom
// rather than assume it.
static_assert(std::atomic<float>::is_always_lock_free,
"the meter folds run on the audio thread and must be lock-free");
// The two windows' identity elements: a peak window that has seen nothing reports silence, a
// gain window that has seen nothing reports no reduction. They are what a consume reinstalls,
// so they live beside the folds rather than at the reader.
inline constexpr float kMeterPeakIdentity = 0.f;
inline constexpr float kMeterGainIdentity = 1.f;
// Folds one block's reading into its accumulator — a running max for a peak, a running min for
// the limiter's gain — so the ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512
// all reach the meter instead of the one it happened to sample.
//
// An UNCONDITIONAL read-modify-write, and that is the whole point. The UI's consume is an
// exchange that can land between a plain load and its store, and a load-compare-store fold
// would then drop the block outright: it decided against storing by comparing with a window the
// UI has since taken, so that block's reading enters neither the old window nor the new one.
// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on
// exit however the two interleave. STRONG, so the loop is bounded by the interference it is
// written against: the audio thread is the only writer besides the UI's single consume, and
// weak's permitted spurious failure would make an unbounded retry count reachable with no
// interference at all. Three calls per block, so the strong form costs nothing measurable.
// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is
// templated only so a test can pin the interleave; it must behave as std::atomic<float>.
template <class Accumulator>
inline void foldPeak(Accumulator& acc, float blockPeak) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
template <class Accumulator>
inline void foldMinGain(Accumulator& acc, float blockMinGain) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
// Takes what the window accumulated and reinstalls the identity element, which IS what starts
// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who).
// Concrete: only the folds have the interleave a test seam buys, and a template over one
// instantiation models nothing.
inline float consumePeak(std::atomic<float>& acc) {
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
}
inline float consumeMinGain(std::atomic<float>& acc) {
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,57 @@
// meter_ballistics.cpp — see meter_ballistics.h.
#include "core/instrument/engine/meter_ballistics.h"
#include <cmath>
namespace reasampler::instrument::engine {
double meterDbFromLinear(double linear) {
if (!(linear > 0.0)) return kMeterFloorDb; // also catches NaN
const double db = 20.0 * std::log10(linear);
return db < kMeterFloorDb ? kMeterFloorDb : db;
}
double meterNormFromDb(double db) {
if (!(db > kMeterFloorDb)) return 0.0; // also catches NaN
if (db >= kMeterTopDb) return 1.0;
return (db - kMeterFloorDb) / (kMeterTopDb - kMeterFloorDb);
}
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds) {
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double fall = kMeterFallDbPerSecond * dt;
const double peakDb = meterDbFromLinear(blockPeakLinear);
MeterState next = prev;
// Instantaneous rise, timed fall — one expression, because a fall can never take the bar
// below the peak this very block carried.
const double fallen = prev.levelDb - fall;
next.levelDb = fallen > peakDb ? fallen : peakDb;
if (next.levelDb >= next.holdDb) {
next.holdDb = next.levelDb;
next.holdRemainingSeconds = kMeterPeakHoldSeconds;
} else {
next.holdRemainingSeconds = prev.holdRemainingSeconds - dt;
if (next.holdRemainingSeconds < 0.0) {
// Spend the overshoot as fall time so the tick's release does not quantize to the
// UI frame it happened to expire on.
const double held = kMeterFallDbPerSecond * -next.holdRemainingSeconds;
const double dropped = next.holdDb - held;
next.holdDb = dropped > next.levelDb ? dropped : next.levelDb;
next.holdRemainingSeconds = 0.0;
}
}
if (blockPeakLinear >= 1.0) next.clip = true;
return next;
}
MeterState clearMeterClip(MeterState prev) {
MeterState next = prev;
next.clip = false;
return next;
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,40 @@
// meter_ballistics.h — the output meter's ballistics and its dB scale: peak fall, peak hold,
// clip latch, and the dB -> normalized map the bar draws against. UI-thread math ONLY: the
// audio thread publishes raw block peaks per block and converts, holds and decays nothing.
#pragma once
namespace reasampler::instrument::engine {
// The scale is LINEAR IN dB across this span. Above 0 dBFS is shown because that is exactly
// what the limiter-off case has to make visible.
inline constexpr double kMeterFloorDb = -60.0;
inline constexpr double kMeterTopDb = 6.0;
// A peak meter must not smooth its attack or it under-reports, so the rise is instantaneous
// and only the fall is timed. 20 dB/s is close to the IEC 60268-18 PPM fallback.
inline constexpr double kMeterFallDbPerSecond = 20.0;
inline constexpr double kMeterPeakHoldSeconds = 1.5;
// Linear magnitude -> dBFS, floored at kMeterFloorDb — a silent block reads the floor rather
// than -inf, so the state stays a finite number the ballistics can subtract from.
double meterDbFromLinear(double linear);
// dBFS -> [0,1] up the meter, clamped at both ends.
double meterNormFromDb(double db);
struct MeterState {
double levelDb = kMeterFloorDb;
double holdDb = kMeterFloorDb;
double holdRemainingSeconds = 0.0;
bool clip = false; // latched; only clearMeterClip lowers it
};
// One UI frame of ballistics against the block peak the audio thread published and the time
// since the previous frame. Clip latches at a block peak >= 0 dBFS and is never cleared here.
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds);
// The click-to-clear on the meter's clip cap.
MeterState clearMeterClip(MeterState prev);
} // namespace reasampler::instrument::engine
@@ -0,0 +1,254 @@
// period_detect — pure implementation. See period_detect.h for the contract.
//
// YIN (de Cheveigne & Kawahara 2002), two-pass: a cumulative-mean-normalized difference
// function on a 4x box-decimated copy picks the period, then the raw difference function at
// full rate refines it to a fraction of a frame. The decimated pass is what makes the cost
// bounded; the full-rate pass is what makes the estimate precise enough to multiply — the
// splice jump is n periods, so an error of e frames lands as n*e frames of misalignment.
//
// Hand-rolled rather than autocorrelation-with-an-FFT: no third-party dependency, and the
// difference function's absolute threshold is what lets "no period here" be a real answer.
#include "core/instrument/engine/period_detect.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace reasampler::instrument::engine {
namespace {
constexpr int kDecimate = 4;
// Below this RMS a block carries no signal to find a period in; its difference function is
// numerically degenerate rather than merely inconclusive.
constexpr double kSilenceRms = 1e-5;
// Box-decimate `src[from, from+count)` by kDecimate. The averaging is the anti-alias filter:
// a plain stride would fold high partials onto the low lags the coarse pass searches.
std::vector<double> decimate(const std::vector<AudioSample>& src, std::size_t from,
std::size_t count) {
std::vector<double> out(count / kDecimate);
for (std::size_t i = 0; i < out.size(); ++i) {
double s = 0.0;
for (int k = 0; k < kDecimate; ++k) {
s += static_cast<double>(src[from + i * kDecimate + static_cast<std::size_t>(k)]);
}
out[i] = s / kDecimate;
}
return out;
}
// The cumulative-mean-normalized difference d'(tau) over lags [1, lagHi], analysis window W:
// d(tau) = sum_{j<W} (x[j] - x[j+tau])^2
// d'(tau) = d(tau) / ((1/tau) * sum_{t=1..tau} d(t))
// Index 0 is unused (set to 1.0, YIN's convention). The normalization is what makes the
// threshold below an absolute one rather than a signal-dependent one.
std::vector<double> cmndf(const std::vector<double>& x, std::size_t W, std::size_t lagHi) {
std::vector<double> dp(lagHi + 1, 1.0);
double running = 0.0;
for (std::size_t tau = 1; tau <= lagHi; ++tau) {
double d = 0.0;
for (std::size_t j = 0; j < W; ++j) {
const double diff = x[j] - x[j + tau];
d += diff * diff;
}
running += d;
dp[tau] = running > 0.0 ? d * static_cast<double>(tau) / running : 1.0;
}
return dp;
}
// Parabolic vertex through (i-1, i, i+1) as an offset in [-0.5, 0.5] from i. Zero at an end
// point or a non-minimum, which leaves the integer lag — benign, and the full-rate pass
// refines it again anyway.
double parabolicOffset(const std::vector<double>& y, std::size_t i) {
if (i == 0 || i + 1 >= y.size()) return 0.0;
const double den = y[i - 1] - 2.0 * y[i] + y[i + 1];
if (!(den > 0.0)) return 0.0; // a minimum has positive curvature
double f = 0.5 * (y[i - 1] - y[i + 1]) / den;
if (f > 0.5) f = 0.5;
if (f < -0.5) f = -0.5;
return f;
}
// YIN's absolute-threshold rule: take the FIRST dip below the threshold, walked down to its
// local bottom — not the global minimum. A periodic signal dips at every multiple of its
// period, so the global minimum is as likely to be 2P or 3P; taking the first dip is what
// makes the answer the fundamental period rather than some harmonic of it.
bool pickPeriod(const std::vector<double>& dp, std::size_t lagLo, double& tauOut,
double& dissimilarity) {
for (std::size_t tau = lagLo; tau + 1 < dp.size(); ++tau) {
if (dp[tau] >= kPeriodDetectThreshold) continue;
std::size_t t = tau;
while (t + 1 < dp.size() && dp[t + 1] < dp[t]) ++t;
tauOut = static_cast<double>(t) + parabolicOffset(dp, t);
dissimilarity = dp[t];
return true;
}
return false;
}
// The raw difference function over [lo, hi] at FULL rate, minimized parabolically. The coarse
// pass already chose which dip; this only says exactly where its bottom is. Amplitude drift
// over the few frames spanned here is negligible, so the unnormalized d() suffices.
double refineFullRate(const std::vector<AudioSample>& pcm, std::size_t from, std::size_t W,
std::size_t lo, std::size_t hi) {
std::vector<double> d(hi - lo + 1, 0.0);
for (std::size_t tau = lo; tau <= hi; ++tau) {
double s = 0.0;
for (std::size_t j = 0; j < W; ++j) {
const double diff = static_cast<double>(pcm[from + j]) -
static_cast<double>(pcm[from + j + tau]);
s += diff * diff;
}
d[tau - lo] = s;
}
const std::size_t best =
static_cast<std::size_t>(std::min_element(d.begin(), d.end()) - d.begin());
return static_cast<double>(lo + best) + parabolicOffset(d, best);
}
double blockRms(const std::vector<AudioSample>& pcm, std::size_t from, std::size_t count) {
double e = 0.0;
for (std::size_t i = 0; i < count; ++i) {
const double x = static_cast<double>(pcm[from + i]);
e += x * x;
}
return std::sqrt(e / static_cast<double>(count));
}
} // namespace
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t spanFrom, std::size_t spanCount) {
if (sampleRate <= 0 || spanCount == 0) return {};
if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {};
const double rate = static_cast<double>(sampleRate);
std::size_t lagHi = longestLagFrames(sampleRate);
const std::size_t lagLo = static_cast<std::size_t>(rate / kPeriodDetectMaxHz);
if (lagLo < 2) return {}; // a rate so low the whole search band collapses
// One probe block is W + lagHi frames with W == lagHi (YIN's usual sizing: the analysis
// window must cover the longest lag being tested). A short span shortens the search
// rather than refusing outright — a 200 ms one-shot still has a period worth finding.
if (spanCount < 2 * lagHi) lagHi = spanCount / 2;
if (lagHi <= lagLo + 2) return {};
const std::size_t block = 2 * lagHi;
// Probe POSITIONS, not disjoint blocks — see kPeriodDetectProbes in the header for why
// lagHi is the separation that makes two overlapping probes independent evidence.
const std::size_t room = spanCount - block;
const std::size_t probes = std::min<std::size_t>(kPeriodDetectProbes, 1 + room / lagHi);
// Room to spare after the last probe's block is spread between them, so the probes sample
// the whole span rather than only its opening.
const std::size_t stride = probes > 1 ? room / (probes - 1) : 0;
std::vector<double> periods;
std::vector<double> confidences;
// Probes that carried signal AND ran the real dip search — the agreement denominator. A
// silent block is no evidence either way; a block whose decimated search band or full-rate
// refine bracket collapsed to nothing (the two geometry continues below) never ran that
// search either, so it is excluded on the same footing as silence, not counted as if it had.
std::size_t evidence = 0;
for (std::size_t p = 0; p < probes; ++p) {
// (probes - 1) * stride <= room by construction, so the last block always fits.
const std::size_t from = spanFrom + p * stride;
if (blockRms(pcm, from, block) < kSilenceRms) continue;
const std::vector<double> small = decimate(pcm, from, block);
const std::size_t smallHi = lagHi / kDecimate;
const std::size_t smallW = small.size() - smallHi;
if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue; // degenerate geometry
const std::vector<double> dp = cmndf(small, smallW, smallHi);
double coarseTau = 0.0, dissimilarity = 1.0;
if (!pickPeriod(dp, std::max<std::size_t>(2, lagLo / kDecimate), coarseTau,
dissimilarity)) {
++evidence; // the search ran and found no dip: real evidence against a period
continue;
}
// Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick:
// the decimated parabola is already sub-decimated-sample accurate, so this is margin,
// not a second search.
const double centre = coarseTau * kDecimate;
const std::size_t lo = static_cast<std::size_t>(
std::max(static_cast<double>(lagLo), centre - 2.0 * kDecimate));
const std::size_t hi = static_cast<std::size_t>(
std::min(static_cast<double>(lagHi), centre + 2.0 * kDecimate));
if (hi <= lo) continue; // degenerate refine bracket
++evidence; // the search ran and found a period: real evidence for one
periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi));
confidences.push_back(1.0 - dissimilarity);
}
if (periods.empty()) return {};
// ONE piece of evidence in the whole span — either it hosted a single probe position, or
// every other probe was silent. Nothing can rule against this estimate, so the accept rests
// on pickPeriod's absolute threshold, which is a real test and not an absence of one: the
// block genuinely repeats at this lag across its whole analysis window. Refusing instead
// would deny every short one-shot a period, and a period that turns out wrong costs a
// mis-centred correlation search at the splice, not an unrefined one (pitch_shift.cpp's
// splice searches +/- maxLag around whichever jump it is handed). Do not "unify" this back
// into the majority test — at one piece of evidence that test accepts unconditionally, which
// is the same behaviour with none of the reasoning. Nor key it on how many probes SURVIVED:
// one survivor out of four that all carried signal is not this case at all.
if (evidence == 1) {
PeriodEstimate lone;
lone.frames = periods[0];
lone.confidence = confidences[0];
return lone;
}
std::vector<double> sorted = periods;
std::sort(sorted.begin(), sorted.end());
const double median = sorted[sorted.size() / 2];
// Average the probes that agree with the median rather than taking the median outright:
// averaging cancels each probe's own estimation jitter, and the jump multiplies whatever
// error survives by n.
double sum = 0.0, confSum = 0.0;
std::size_t agree = 0;
for (std::size_t i = 0; i < periods.size(); ++i) {
if (std::fabs(periods[i] - median) > kPeriodDetectAgreeTolerance * median) continue;
sum += periods[i];
confSum += confidences[i];
++agree;
}
// A STRICT MAJORITY of the probes that carried signal must agree, not merely two of them: a
// source whose first half is one period and second half another gives two probes each way,
// and taking either as "the" period would misalign every splice in the other half. Refusing
// is the right answer there — the fixed-window fallback is what a source with no ONE period
// gets. The denominator is `evidence` and not `periods.size()` because once probes overlap
// a straddling block finds no period at all rather than a third one, and counting only the
// survivors turned that two-and-two split into a two-of-three accept.
// Reached only with two or more pieces of evidence; the lone case returned above.
if (agree * 2 <= evidence) return {};
PeriodEstimate est;
est.frames = sum / static_cast<double>(agree);
est.confidence = confSum / static_cast<double>(agree);
return est;
}
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate) {
return detectPeriod(pcm, sampleRate, 0, pcm.size());
}
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate) {
const AnalysisSpan whole{0, frameCount};
if (!hasLoop || sampleRate <= 0) return whole;
if (loopStart < 0 || loopEnd <= loopStart) return whole;
if (static_cast<std::uint64_t>(loopEnd) > frameCount) return whole;
const std::size_t length = static_cast<std::size_t>(loopEnd - loopStart);
// One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the
// lowest findable fundamental — the one thing the narrower span may never cost.
const std::size_t minimum = 2 * longestLagFrames(sampleRate);
if (length < minimum) return whole;
return AnalysisSpan{static_cast<std::size_t>(loopStart), length};
}
} // namespace reasampler::instrument::engine
+122
View File
@@ -0,0 +1,122 @@
#pragma once
// period_detect — the source's own fundamental period, estimated ONCE per load from decoded
// PCM, for the Preserve splice's pitch-synchronous jump (pitch_shift.h's periodAlignedJump).
//
// Runs off the audio thread BY LINK GRAPH: sampler_core does not link this module, so no
// translation unit on the render path can name detectPeriod. A sampler's source is fixed and
// fully known at load, which is the whole reason a detector is affordable here at all.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::engine {
using audio::AudioSample;
// The period the source repeats at, in SOURCE frames, or none. Derived from the audio, never
// authored and never persisted — this is a cache, not state.
struct PeriodEstimate {
double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise)
// 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision
// is `valid()` alone and the loader takes `.frames` without reading this — its only reader is
// tests/test_period_detect.cpp. It is deliberately NOT a second accept gate: every
// accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by
// construction and any gate below that is a no-op while any gate above it is a tuned number
// with nothing to derive it from.
double confidence = 0.0;
bool valid() const { return frames > 0.0; }
};
// Fundamental bounds the search runs over. The LOW bound is the load-bearing one: a period
// only buys anything while it fits the splice's reachable jump (~1.25 windows, i.e. ~16 Hz at
// the product's 50 ms window), so searching below it would return periods the shifter must
// reject anyway. The high bound is generous — a period that short already has dozens of
// aligned landing points inside the search interval, so alignment was never in question there.
inline constexpr double kPeriodDetectMinHz = 15.0;
inline constexpr double kPeriodDetectMaxHz = 2000.0;
// YIN's absolute threshold: the first dissimilarity dip below this IS the period. A source
// that never dips below it has no single period, and detection returns none rather than the
// global minimum — the difference between "quiet but real" and "the least bad of nothing".
inline constexpr double kPeriodDetectThreshold = 0.12;
// The longest lag searched, in frames — THE one derivation of it. A probe block is twice this,
// and `periodAnalysisSpan`'s minimum is one block; both read this rather than re-deriving the
// same expression, so "choosing the loop never narrows the search band" is a fact and not a
// coincidence between two literals.
inline std::size_t longestLagFrames(int sampleRate) {
return static_cast<std::size_t>(static_cast<double>(sampleRate) / kPeriodDetectMinHz);
}
// How many blocks across the sample are estimated independently, and how far apart two of them
// may land and still be called the same period. Agreement is what separates a genuinely
// periodic source from one whose opening happens to look periodic.
//
// Probes are placed by POSITION and may overlap: what the rule needs is estimates from
// different places in the source, and two blocks a full longest-lag apart already differ by a
// whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy
// of the other at any period searched. Requiring DISJOINT blocks instead left every source
// under ~4x the longest lag with a single probe and so with no agreement to check at all.
inline constexpr int kPeriodDetectProbes = 4;
inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median
// Estimates the fundamental period of `pcm[from, from+count)` at `sampleRate`. Cost is bounded
// by the constants above, not by the span length: at most kPeriodDetectProbes blocks of ~2 x
// the longest searched lag are analysed however long the span is. Allocates; never call from
// process(). An out-of-range span estimates nothing and returns none.
//
// Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes
// disagree — the caller's documented fallback is the fixed-window splice geometry.
//
// A STRICT MAJORITY of the probes that CARRIED SIGNAL must agree. Silence is excluded from that
// denominator and a failure to find a period is not: a silent block is no evidence either way,
// whereas a block that carries signal and repeats at no lag is evidence against a single period.
// A capture with a silent head or tail therefore still detects, while a mostly-noise source with
// one pitched burst is refused rather than accepted on that burst alone. A LONE piece of
// evidence — the whole span too short for a second probe position, or every other probe silent —
// is accepted on the absolute threshold alone, because there is nothing to rule against it and
// refusing would deny every short one-shot a period.
//
// The answer is NOT monotone in span length, and cannot be made so: no rule that refuses a
// two-and-two split at four probes can also accept a lone probe unconditionally, and the probe
// count steps at 3x, 4x, 5x and 6x the longest lag before saturating. What IS pinned, by a
// length sweep in the tests, is that a STATIONARY source detects at every length — a source
// whose period varies by more than kPeriodDetectAgreeTolerance is the only class that moves
// with the count, and refusing it is this contract's own answer.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t from, std::size_t count);
// The whole source.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate);
// The frames detection should analyse for a capture that carries a sustain loop, and the reason
// the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the
// splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise
// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it is
// at least one full probe block — `2 * longestLagFrames(sampleRate)`, the span below which
// detectPeriod starts shortening its own search band — so choosing the narrower span never costs
// search-band WIDTH. It can still change the ANSWER: the agreement rule rules on content, so a
// source periodic over most of its length whose loop region is noisy detects whole and refuses
// over the loop. That is the intent — the loop is what a Gate voice plays.
// Anything else (no loop, an out-of-range span, a short one) yields the whole source.
//
// It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the
// loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span
// this cache was derived from — whereas play mode's exclusion from live delivery is a listed,
// reversible decision (deck_groups' deckParamCommit). Keying a load-time cache on it would work
// today and silently serve a stale period the day that decision is revisited.
//
// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is
// on a cache input, not a second validity rule, and it refuses rather than repairs the same way.
struct AnalysisSpan {
std::size_t from = 0;
std::size_t count = 0;
};
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate);
} // namespace reasampler::instrument::engine
+84 -28
View File
@@ -1,10 +1,12 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
//
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
// sample (source rate, duration preserved). One active read tap advances by the shift
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
// one window — clamped to the filled span so it never lands in unwritten silence — refined
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per source
// frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame,
// so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one
// frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump (one
// window, or the nearest whole number of source periods to it once setSourcePeriod names one)
// — clamped to the filled span so it never lands in unwritten silence — refined
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
@@ -25,6 +27,23 @@ constexpr double kPi = 3.14159265358979323846;
} // namespace
std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames,
double periodFrames) {
if (windowFrames <= 1 || maxJumpFrames < 1) return windowFrames;
if (!(periodFrames > 0.0)) return windowFrames;
if (periodFrames > static_cast<double>(maxJumpFrames)) return windowFrames;
std::int64_t n = static_cast<std::int64_t>(
static_cast<double>(windowFrames) / periodFrames + 0.5);
if (n < 1) n = 1;
std::int64_t jump = static_cast<std::int64_t>(periodFrames * static_cast<double>(n) + 0.5);
while (jump > maxJumpFrames && n > 1) {
--n;
jump = static_cast<std::int64_t>(periodFrames * static_cast<double>(n) + 0.5);
}
if (jump < 1 || jump > maxJumpFrames) return windowFrames;
return jump;
}
void PitchShifter::configure(std::int64_t windowFrames) {
window_ = windowFrames;
if (window_ <= 1) {
@@ -36,8 +55,11 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fading_ = false;
fadePos_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
period_ = 0.0;
jump_ = jumpMax_ = 0;
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
@@ -60,6 +82,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
// The delay band is (dHigh_ - dLow_) wide and the search can add up to maxLag_ on either
// side; one frame more than that and a jump could land exactly ON a trigger boundary.
jumpMax_ = std::max<std::int64_t>(1, dHigh_ - dLow_ - maxLag_ - 1);
fadeLen_ = 0;
reset();
}
@@ -85,15 +110,23 @@ void PitchShifter::reset() {
}
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::setSourcePeriod(double periodFrames) {
period_ = periodFrames > 0.0 ? periodFrames : 0.0;
jump_ = window_ > 1 ? periodAlignedJump(window_, jumpMax_, period_) : 0;
}
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// ratio-feedRate per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
@@ -158,6 +191,10 @@ void PitchShifter::setShiftRatio(double ratio) {
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
}
void PitchShifter::setFeedRate(double rate) {
if (rate > 0.0) feedRate_ = rate;
}
double PitchShifter::readTap(double pos) const {
// Fractional linear interpolation with ring wrap.
double p = pos;
@@ -192,7 +229,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
if (jump > maxJump) jump = maxJump;
// Shortening a period-aligned jump to fit must land on a SHORTER MULTIPLE, not on the
// raw bound — a clamped jump is an unaligned one, which is the whole failure this
// module now avoids. With no period known (or none fitting) this is the bare clamp.
if (jump > maxJump) jump = periodAlignedJump(maxJump, maxJump, period_);
if (jump < 1) jump = 1;
}
// The correlation reference reads FORWARD from the tap; keep it strictly behind the
@@ -266,18 +306,19 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
while (p >= len) p -= len;
posA_ = p;
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
// so they always keep the full fade.
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts,
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those
// always keep the full fade.
//
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
// applies at every ratio (including unity, since delay now drains at unity too).
// either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies
// at every ratio (including unity, since delay now drains at unity too).
fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
if (drainRate > 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
@@ -310,14 +351,27 @@ void PitchShifter::applySplice(const SpliceEvent& ev) {
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
}
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr, true); }
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
return processImpl(in, &master);
return processImpl(in, &master, true);
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
AudioSample PitchShifter::processNoInput() { return processImpl(0.0f, nullptr, false); }
AudioSample PitchShifter::processNoInputLinked(const SpliceEvent& master) {
return processImpl(0.0f, &master, false);
}
void PitchShifter::writeFrame(AudioSample in) {
if (window_ <= 1 || tailFrozen_) return;
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
if (++writePos_ >= ringLen_) writePos_ = 0;
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, bool write) {
if (window_ <= 1) return write ? in : 0.0f; // pass-through (unconfigured / degenerate)
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
@@ -325,8 +379,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
// below run unchanged over the frozen content.
if (!tailFrozen_) {
// below run unchanged over the frozen content. A starved stretch frame (`write` false) takes
// the identical shape: no input was due this output frame, so there is nothing to write.
if (write && !tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
}
@@ -357,9 +412,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
splice(-jump_, d);
}
}
} else {
@@ -372,14 +427,15 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
splice(-jump_, d);
}
}
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
if (!tailFrozen_) {
// Advance heads: write head one frame (parked while tail-frozen or starved), tap(s) by the
// shift ratio.
if (write && !tailFrozen_) {
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
}
+77 -6
View File
@@ -1,11 +1,18 @@
#pragma once
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core).
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// `readPos_ += ratio_` resample path).
// relocated by a nominal jump, refined by a cross-correlation search so the new read point is
// waveform-aligned, then old/new taps crossfade (raised-cosine). The nominal jump is a whole
// number of the SOURCE's own periods when setSourcePeriod names one (pitch-synchronous OLA),
// and the fixed window otherwise.
//
// The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio =
// pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via
// process() holds duration and moves pitch; feeding faster/slower via writeFrame() /
// processNoInput() moves duration at whatever pitch the tap is set to. Nothing here resamples
// to preserve duration — the splice/overlap-add IS the pitch-preserving mechanism, which is
// what the "WDL_Resampler is not a Preserve engine" invariant asks for.
//
// Regression history — do not revert any of these:
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
@@ -56,6 +63,21 @@ struct SpliceEvent {
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// The nominal splice jump for a source whose period is known: the multiple of `periodFrames`
// nearest `windowFrames` that still fits `maxJumpFrames`. Falls back to `windowFrames` — the
// pre-PSOLA geometry, exactly — whenever the period is unknown (<= 0) or too long for even one
// whole period to fit, which is the documented degradation for inharmonic, polyphonic,
// percussive and noise sources.
//
// Why this is the whole fix: a splice can only phase-align on a landing point that is a whole
// number of source periods away, and the correlation search only reaches [0.75, 1.25] windows.
// Periods with no multiple in that one interval — f < ~16 Hz, and 26.7-32 Hz at a 50 ms
// window — could never align, however good the search was. Making the NOMINAL a multiple puts
// an aligned point at the centre of the search rather than hoping one falls inside it. The
// jump is rounded to whole frames; the search's own sub-sample refinement absorbs the residue.
std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames,
double periodFrames);
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// two rings stay sample-aligned.
@@ -89,6 +111,25 @@ public:
// ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio);
// Source frames written per output frame — 1.0 unless the caller is stretching. Used ONLY
// to size a splice crossfade safely: the outgoing tap closes on the write head at
// (ratio - feedRate) per frame, so a fade sized against an assumed 1.0 overruns when the
// source is fed slower than the output runs and the tail of the fade reads lapped content.
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate);
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
// number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
// default, so a caller that never calls this sees no change.
// Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately
// does not link); this is a couple of divisions and is safe to call at note-on.
// Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source.
void setSourcePeriod(double periodFrames);
// The nominal jump splices currently use — window() unless a source period retuned it to
// the nearest whole-period multiple, which can land either narrower or wider than window().
std::int64_t spliceJump() const { return jump_; }
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
@@ -103,6 +144,20 @@ public:
// their ring state advances in lockstep. RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
// Writes one source frame WITHOUT producing an output frame — the stretch path's surplus
// input when the source is consumed faster than the output runs. No splice can fire here:
// splices are decided on the read side. No-op while unconfigured or tail-frozen, and it
// deliberately leaves lastSplice_ alone so a linked follower's schedule is unaffected.
// RT-safe.
void writeFrame(AudioSample in);
// Produces one output frame WITHOUT consuming a source frame — the stretch path's starved
// output frame when the source is consumed slower than the output runs. Identical to
// process()/processLinked() in every other respect. Returns 0 while unconfigured (there is
// no input to pass through). RT-safe.
AudioSample processNoInput();
AudioSample processNoInputLinked(const SpliceEvent& master);
const SpliceEvent& lastSplice() const { return lastSplice_; }
// Call once the source stream is exhausted — no real frame remains to feed process().
@@ -137,7 +192,9 @@ private:
void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
// `write` false is the starved stretch frame: read/splice/advance the taps, but consume no
// input and hold the write head (the same shape tail-freezing already takes).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write);
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -152,6 +209,19 @@ private:
// ratio-scaled at splice time so an up-shift's outgoing
// tap can never drain into the writer mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window)
std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
// inside [dLow_, dHigh_] at the worst search lag, so a
// period-sized jump can never land back on a trigger and
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
// jump_==jumpMax_ a DOWN-splice's correlation read comes
// within ~41 frames of the write head (measured: the
// exact ring/lag/corrFrames_ geometry at the product
// window, worst case over every lag the search reaches) —
// real margin, not zero, but tight enough that widening
// maxLag_, corrFrames_ or jumpMax_ without re-deriving
// this bound risks reading unwritten ring content.
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer by construction at an up-splice)
@@ -161,6 +231,7 @@ private:
// clamps its up-jump to this so it never lands in
// unwritten silence
double ratio_ = 1.0; // current shift ratio (>0)
double feedRate_ = 1.0; // source frames written per output frame; splice-fade only
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
// at the top of every frame, set on a splice
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
+23 -1
View File
@@ -148,6 +148,11 @@ struct FilterParams {
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
// instrument's stored scalar and the live block all default from here, so a blob predating the
// field and a block published before the first note can never disagree about it.
inline constexpr double kKeyTrackDefault = 1.0;
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
// on this; the Preserve product default is layered on at (de)serialization, see
@@ -158,6 +163,15 @@ struct PlayParams {
TriggerParams trigger; // Trigger play span
AhdParams trigAhd; // Trigger amp
PitchEngine pitchEngine = PitchEngine::Varispeed;
// Playback RATE, as source frames consumed per output frame. Under Varispeed it is one more
// factor of the read increment, so it moves pitch and duration together; under Preserve it
// drives duration alone and the shifter holds the pitch. Latched at note-on either way (the
// loop fold and the contour scale it composes with are both note-on folds), and clamped by
// the stretcher's own clampStretchRate — never here. 1.0 is the bare engine, bit for bit.
double playRate = 1.0;
// A baseline pitch offset in semitones, folded into the note's ratio beside key-tracking and
// the velocity->pitch transpose. Live on a sounding voice under both engines.
double pitchOffsetSemitones = 0.0;
PitchEnvParams pitchEnv;
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
@@ -258,12 +272,20 @@ struct SampleData {
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// (never per frame). Default flat y=1 — every velocity plays at unity.
VelocityCurve velocityCurve = VelocityCurve::flat();
// The source's own fundamental period in SOURCE frames, which makes Preserve's splices
// pitch-synchronous (pitch_shift.h). DERIVED from the PCM at load, not authored and never
// persisted — a cache, not state, so it takes no rung of the payload ladder. 0 means
// unknown (nothing detected it, or the source has no single period) and restores the
// fixed-window splice geometry byte for byte, which is why a hand-built SampleData is
// still exactly the bare engine.
double sourcePeriodFrames = 0.0;
PlayParams play;
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
+132
View File
@@ -0,0 +1,132 @@
#pragma once
// time_stretch — the Preserve engine's TIME half: how fast the source is consumed, given a
// playback rate. It pairs with pitch_shift's PITCH half (how fast the ring's read tap runs);
// the two rates are independent over one delay ring, and only their difference reaches the
// splice machinery. Header-inline: every member sits on the per-voice-per-sample feed.
#include <cstdint>
#include "core/instrument/engine/loop/loop_span.h"
namespace reasampler::instrument::engine {
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame
// source frames) — the RT-safety argument for feeding a variable count at all.
//
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output
// frames (the tap's delay drifts across one nominal jump at that per-frame rate); the shifted
// tone's own period is `sourcePeriod / shift` output frames. Whenever the recurrence interval
// is shorter than that period, a splice lands inside a single perceived cycle and the
// correlation search has less than one period to align against. Measured at rate 4.0, shift
// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785
// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling
// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval
// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter)
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it
// checks, since splice debris fools that estimator into reading the wrong period on a render
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
//
// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of
// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead
// (pitch_shift.h), and that answer can land NARROWER than window() — as low as ~0.63*window for
// some periods — which SHRINKS the interval and moves the failure threshold EARLIER, not later.
// There is no single closed-form floor for this case (the jump is itself a function of P), so
// read it at the concrete corner instead: at P=1470 (30 Hz at 44.1k) the same rate 2.0/shift
// 0.25 corner's jump narrows from window (2205) to 1470, and its interval from 1260 to
// 1470/1.75 = 840. Independently, at the plain (no time-stretch) rate 1.0 case, solving this
// same inequality for shift at P=1470 puts the failure threshold at shift = P/(jump+P): 0.4
// (-16 st) at the fixed-window jump (2205), 0.5 (-12 st) at the pitch-synchronous jump (1470) —
// the geometry fix that lets 30 Hz align AT ALL moves this unrelated cadence inequality's own
// trip point from roughly -16 st to roughly -12 st for the same source. Do NOT read this as a
// proven regression: the inequality above was calibrated for RANDOM-PHASE (unaligned) splices,
// and a pitch-synchronous splice is waveform-aligned by construction, which the inequality does
// not model — whether the shorter interval still produces audible debris once every splice
// lands in phase is what pitch_shift_tests' own P=1470 cadence-collapse-band measurement
// answers, not this derivation. Do not narrow kStretchRateMin/kStretchRateMax in response to
// this: sub-50 Hz sine material is first-class product material, not an edge case, and a
// narrower range does not fix a floor it does not reach.
//
// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
// CLOSED for any source whose period is detected, but the geometry is worth keeping because it
// is what the fixed-window fallback still lives under. A splice relocated the tap by the
// nominal window refined by a search over +/- window/4, so the reachable relocation distances
// were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source
// periods inside that interval. The interval is 0.5*window wide, so any period <= window/2
// always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers
// periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was
// reachable by nothing. Because both the interval and the period scale with the sample rate,
// that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and
// 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch
// stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at
// rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone
// itself landed 7.4% flat (-133 cents).
//
// The fix is not a wider window: it is a nominal jump that is a whole number of the source's
// own periods, so an aligned landing point exists by construction (pitch_shift.h's
// periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and
// 0.00%, and 29 Hz renders at +0.0 cents — all from `preserve_low_frequency_tests` (Release,
// hand-run; it is not in the gated ctest set), the same harness/config as the 3.6%/15.5%/-133
// cents readings above. The gated suite's own number for this is the floor-relative excess in
// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different
// quantity from the raw percentages here. What survives: a period longer than the reachable
// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no
// single period falls back to it by design (periodAlignedJump, pitch_shift.h).
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
// run the tap backward). 1.0 in gives exactly 1.0 out, which is what keeps the unity read
// bit-identical.
inline double clampStretchRate(double rate) {
if (!(rate > 0.0)) return 1.0;
if (rate < kStretchRateMin) return kStretchRateMin;
return rate > kStretchRateMax ? kStretchRateMax : rate;
}
// One Preserve voice's source-feed schedule: a fractional source cursor answering, per OUTPUT
// frame, which whole source frames fall due. At rate 1.0 that is exactly one frame per output
// frame with no residue carried — bit for bit the pre-stretch feed.
class StretchCursor {
public:
// `frame` is where the ring prime stopped; the per-frame feed continues there.
void start(std::int64_t frame) {
frame_ = frame;
debt_ = 0.0;
}
// Adds one output frame's worth of source at `rate` and returns how many whole source
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
// lives here rather than at the caller because this return value is the loop bound.
std::int64_t due(double rate) {
debt_ += clampStretchRate(rate);
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
debt_ -= static_cast<double>(whole);
return whole;
}
// The next due source frame, wrapped into the sustain loop, advancing the cursor past it.
// Advances even past the playable span — the caller freezes the shifter's writer there, and
// a cursor that stalled instead would re-feed one frame forever.
std::int64_t next(const loop::ResolvedLoop& lp) {
if (lp.active) {
while (frame_ >= lp.end) frame_ -= lp.length;
}
return frame_++;
}
std::int64_t frame() const { return frame_; }
private:
std::int64_t frame_ = 0;
double debt_ = 0.0; // fractional source frames carried into the next output frame
};
} // namespace reasampler::instrument::engine
+65 -36
View File
@@ -18,7 +18,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
}
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
double stretchRate, double keyTrack, double lengthFraction) {
// Before any state reset, record the pre-cut reference (last rendered output) and mark
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
@@ -52,13 +53,30 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
sample_ = &sample;
const PlayParams& p = sample.play;
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below).
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ rather than
// costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed read-rate
// bias and Preserve shift amount both derive from it below).
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones);
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for
// a rate this moves. Clamped once here so the read head's increment and the feed cursor's
// debt accumulate the SAME value: they must stay exactly one window apart for the note's
// whole life.
stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
// Keyed on the read path this note will ACTUALLY take, which is not the same question as
// the stored engine: advanceFrame runs the Preserve branch only while the shifters are
// configured, and a Preserve voice whose shifters were never sized falls back to the
// varispeed read. Rate has to reach the increment there too, or that fallback would ignore
// the control outright — the predicate is spelled the same way advanceFrame spells it.
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
recomputeBaseRatio();
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
// Pitch 0 it is a division by exactly 1.0.
pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_;
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
// rather than starting a voice already off the end.
@@ -103,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else {
// 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);
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
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>(
@@ -115,23 +132,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
if (playLen > postStart) playLen = postStart;
playEnd_ = start + playLen;
trigSpan = playLen;
ampAhd_.configure(playLen, p.trigAhd);
ampAhd_.configure(playLen, rateFittedAhd(p.trigAhd));
}
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
// rate, so its two domains already coincide.
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
// Strictly better than the un-converted source-frame span it replaced.
const double pitchSpan =
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
? static_cast<double>(postStart)
: static_cast<double>(postStart) / baseRatio_;
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
// stages lay 1:1 over the waveform from the start point. The source->output conversion, and
// why it is only first-order, are pitchEnvSpanFrames' own (voice.h).
pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv);
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
@@ -165,7 +172,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
} else {
filterAhd_.configure(trigSpan, p.filter.trigEnv);
filterAhd_.configure(trigSpan, rateFittedAhd(p.filter.trigEnv));
}
filter_.reset();
updateFilterCutoffBase(note);
@@ -234,7 +241,15 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
// playable span).
feedPos_ = p;
stretch_.start(p);
shiftL_.setFeedRate(stretchRate_);
shiftR_.setFeedRate(stretchRate_);
// Pitch-synchronous splices: the period was detected once at load (period_detect,
// which this library deliberately does not link — the loader hands the answer down on
// SampleData). 0 restores the fixed-window geometry, so a capture with no single
// period plays exactly as it always did.
shiftL_.setSourcePeriod(sample.sourcePeriodFrames);
shiftR_.setSourcePeriod(sample.sourcePeriodFrames);
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is already exhausted at prime time.
shiftL_.freezeTail();
@@ -251,15 +266,26 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
//
// live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class,
// delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The
// latched stretchRate_ is what stageFitRate carries into every conversion below, so a
// stage-time move mid-note lands in this note's own rate domain rather than resetting it.
const bool gate = (playMode_ == PlayMode::Gate);
// The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_
// up as one more factor of next frame's read increment, Preserve as the shifter's transpose.
// Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate
// both of them are fitted against — a stale offset here would fit them to the previous move.
pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones);
recomputeBaseRatio();
if (snap) {
if (gate) env_.snapLive(live.adsr);
else ampAhd_.snapLive(live.ampAhd);
pitchEnv_.snapLive(live.pitchEnv);
else ampAhd_.snapLive(rateFittedAhd(live.ampAhd));
pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv);
} else {
if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
pitchEnv_.applyLive(live.pitchEnv);
else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd));
pitchEnv_.applyLive(pitchEnvSpanFrames(), live.pitchEnv);
}
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
@@ -271,10 +297,10 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
if (snap) {
if (gate) filterEnv_.snapLive(live.filterEnv);
else filterAhd_.snapLive(live.filterAhd);
else filterAhd_.snapLive(rateFittedAhd(live.filterAhd));
} else {
if (gate) filterEnv_.applyLive(live.filterEnv);
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd));
}
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
@@ -312,15 +338,18 @@ void Voice::retune(int note) {
// legato phrase is one gesture, one strike (classic mono-synth behavior).
if (!active_ || sample_ == nullptr) return;
note_ = note;
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
// first note's domain — consistent with "touch nothing else," but the drift lives here.
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span
// (pitchEnvSpanFrames, whose base rate this deliberately does not move), so a slide leaves
// that envelope on the first note's domain — consistent with "touch nothing else," but the
// drift lives here.
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
// one gesture, one strike.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
// one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved.
recomputeBaseRatio();
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
if (filterOn_) updateFilterCutoffBase(note);
// stretchRate_ (Preserve's duration control) is untouched here too — it is a note-on latch
// like velocityGain_, not a per-note property to re-resolve on a legato slide.
}
void Voice::release() {
+179 -50
View File
@@ -19,6 +19,7 @@
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/time_stretch.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler {
@@ -44,17 +45,25 @@ inline double pitchRatio(int note, int rootNote) {
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
// At the root note the offset is 0 regardless of keyTrack.
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
inline constexpr double kLatchFromSnapshot = -1.0;
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0);
}
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
// 2^(semitones/12). Exactly 1.0 at zero — and it SKIPS the pow there, so an unset offset
// transposes nothing and costs nothing.
inline double semitoneRatio(double semitones) {
return (semitones == 0.0) ? 1.0 : std::pow(2.0, semitones / 12.0);
}
// The velocity->pitch transpose, which the voice folds into baseRatio_ once at note-on. A curve
// flat at 0 — the default — yields EXACTLY 1.0 at every velocity.
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
return semitoneRatio(curve.eval(static_cast<double>(velocity)) *
kVelocityPitchRangeSemitones);
}
// One octave expressed in the cutoff control's normalized domain, read out of the filter
@@ -108,7 +117,26 @@ public:
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
// the difference-seeded declick compensation on the first frame after the restart (see
// kDeclickDecay above). A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
//
// `stretchRate` is the playback rate — source frames consumed per output frame, clamped to
// [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an argument, not
// a member set separately) because the loop fold and the contour scale it composes with are
// both note-on folds. Under Preserve it is the stretcher's feed rate and duration alone moves;
// under Varispeed it folds into the read increment beside key-tracking, so pitch moves with
// it. 1.0 is the bare engine, bit for bit, in both. Defaulted so a caller with no live block
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
// possibly-stale copy of it.
//
// `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
// class and arrive the same way, for the same structural reason. Negative = not supplied,
// which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
// both are non-negative by domain, so the sentinel can never collide with a real one.
// VoiceEngine::startVoice always supplies them, resolved from the published block when there
// is one; the sentinel is for a caller that has no block to consult.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
double lengthFraction = kLatchFromSnapshot);
// Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
@@ -176,6 +204,65 @@ public:
}
private:
// THE fold of every pitch factor that is constant for the note into one number, so
// advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
// always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
// and the Rate ratio — which start() zeroes out of this product when the note is running the
// Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
}
// The rate the read head consumes SOURCE at, counting only the factors whose stage-time
// coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
// transposes inside the shifter and never touches the read. Under Varispeed both Rate and
// Pitch are factors of the read increment and both are compensated: they are two views of one
// multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
// velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
// sounds, and compensating them would move every note off the root.
double stageFitRate() const {
return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
}
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
// sustain-less envelopes are evaluated in (sourceOffset()). The read stretches the source
// span those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate —
// multiplying by the read rate is exactly that conversion. A fit of exactly 1.0 (Rate 100 %,
// Pitch 0 st) returns the argument untouched, which is what keeps the unity render
// bit-identical.
AhdParams rateFittedAhd(const AhdParams& a) const {
const double fit = stageFitRate();
if (fit == 1.0) return a;
AhdParams out = a;
out.attackFrames =
static_cast<std::int64_t>(static_cast<double>(a.attackFrames) * fit + 0.5);
out.decayFrames =
static_cast<std::int64_t>(static_cast<double>(a.decayFrames) * fit + 0.5);
return out;
}
// The pitch AHD's span. That envelope counts OUTPUT frames while its Hold fraction is taken
// against the playable SOURCE span, so the span converts by the rate the read head consumes
// source at. Divides by that alone though the Varispeed read rate is really baseRatio_ x
// envFactor: a deep pitch envelope makes it a first-order approximation, not exact.
//
// Shared by note-on and every live re-application, so a live Pitch move re-fits the envelope
// rather than leaving it on the offset the note started at. Only that live factor is
// re-read — pitchSpanBaseRate_ has it divided out — which is what leaves a legato retune's
// documented drift (retune) exactly where it was.
std::int64_t pitchEnvSpanFrames() const {
if (sample_ == nullptr) return 0;
const double postStart = static_cast<double>(
static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
const double readRate =
preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
const double span = (readRate > 0.0) ? postStart / readRate : postStart;
return static_cast<std::int64_t>(span + 0.5);
}
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
// its opening value.
@@ -184,9 +271,9 @@ private:
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
// the voice.
// is evaluated at the source offset (readPos - startFrame), which is why its stage frames are
// fitted to the read rate at configure time (rateFittedAhd). Sets amplitudeDone_ on finish so
// advanceFrame frees the voice.
double tickAmplitude() {
double amp;
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
@@ -441,56 +528,78 @@ private:
// and the amp envelope shapes the filtered result (drive included).
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// Feed the shifters the source stream at unity rate (duration held) and transpose
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
// rings were primed with that window at start()), under the same sustain-loop wrap
// rule, reading integer source frames (nothing to interpolate). Past the last real
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
if (loop.active) {
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
}
// feedPos_ runs one window ahead of readPos_; the last real source frame is
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
// the source is exhausted — feeding the held last sample instead would give the
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
// cadence, growing toward the note end). Freezing the shifter's writer means no
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
// the source is FED at stretchRate_, and the tap is SHIFTED by
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
// primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames into the ring — no RATE-DEPENDENT interpolation
// (unlike Varispeed's readPos_ below). The shifter's own read tap still carries a
// splice's sub-sample `frac` (pitch_shift.cpp), so it interpolates on every read,
// splice or no; that constant fractional delay is not a rate coupling.
const bool stereoOut = stereo && haveR && shiftR_.configured();
// The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate.
// Once the feed reaches that bound the source is exhausted — feeding the held last
// sample instead would give the splice correlation a DC plateau it can't align on
// (periodic troughs at the splice cadence, growing toward the note end). Freezing the
// shifter's writer means no padding ever enters the ring, so the splice machinery
// keeps recycling the frozen all-real tail — a continuous tone through the voice's
// own end. The sustain-loop path never gets here: the wrap keeps the cursor inside
// the loop forever.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
const AudioSample feedL =
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
if (stereoOut) shiftR_.setShiftRatio(shift);
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
// written without producing output; the last rides the ordinary 1-in-1-out
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const std::int64_t due = stretch_.due(stretchRate_);
AudioSample feedL = 0.0f, feedR = 0.0f;
bool fed = false;
for (std::int64_t k = 0; k < due; ++k) {
if (fed) { // an earlier frame of this batch: write-only, no output
shiftL_.writeFrame(feedL);
if (stereoOut) shiftR_.writeFrame(feedR);
}
const std::int64_t q = stretch_.next(loop);
if (q >= feedBound) {
shiftL_.freezeTail(); // idempotent; input ignored while frozen
if (stereoOut) shiftR_.freezeTail();
feedL = feedR = 0.0f;
} else {
const double xw = crossfadeWeight(loop, static_cast<double>(q));
feedL = crossfadedSource(pcm, loop, q, xw);
if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw);
}
fed = true;
}
const double shiftedL =
fed ? static_cast<double>(shiftL_.process(feedL))
: static_cast<double>(shiftL_.processNoInput());
outL = shiftedL;
if (stereo) {
if (haveR && shiftR_.configured()) {
if (stereoOut) {
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
// splice decisions via processLinked — one correlation search, one lag, one
// splice schedule for both channels (standard stereo SOLA). An independent
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
// every splice: stereo image wander at the splice cadence + mono-sum
// combing. Each shifter is still processed EXACTLY ONCE per output frame
// (never twice — that would advance its heads twice and corrupt the state).
// (never twice — that would advance its heads twice and corrupt the state);
// the batch's earlier frames go through writeFrame, which produces none.
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
// primes it for genuinely stereo samples, and a stale un-primed ring must
// not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR =
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
fed ? static_cast<double>(
shiftR_.processLinked(feedR, shiftL_.lastSplice()))
: static_cast<double>(
shiftR_.processNoInputLinked(shiftL_.lastSplice()));
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
// shifted value from the mono feed; mirror it to R. Do NOT call
@@ -498,9 +607,13 @@ private:
outRlocal = shiftedL;
}
}
++feedPos_;
// Preserve advances the read head at the SOURCE rate (duration preserved).
ratio_ = 1.0;
// Preserve advances the read head at the STRETCH rate — the one duration control.
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
// therefore stays a source-frame fact and scales by construction.
//
// The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
// this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
ratio_ = stretchRate_;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
@@ -604,8 +717,22 @@ private:
bool releasing_ = false;
int note_ = 0;
double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
double baseRatio_ = 1.0; // recomputeBaseRatio's product: every constant pitch factor
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
// Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
// snapshot so a legato retune and a live block re-apply the note's own value; a published
// move reaches the next note only.
double keyTrack_ = kKeyTrackDefault;
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
// note-on beside rateRatio_, which start() resolves from the same predicate.
bool preserveRead_ = false;
// baseRatio_ with the live Pitch factor divided back out, latched at note-on: what
// pitchEnvSpanFrames multiplies the CURRENT offset onto. Exact at Pitch 0 (the factor is
// exactly 1.0), which is what keeps the unity span bit-identical.
double pitchSpanBaseRate_ = 1.0;
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
@@ -676,9 +803,10 @@ private:
//
// The shifter rings are primed at start() with the first window of the actual upcoming
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
// silence, and splices always land in real history. feedPos_ is the integer source frame
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
// silence, and splices always land in real history. stretch_ is the integer source frame
// fed to the shifters next plus the fractional rate debt; it runs one window ahead of
// readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one
// window apart at every stretch. Once it passes the last real frame (Gate: sample end;
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
// primeBuf_ is the presized scratch the prime stream is assembled into.
@@ -686,7 +814,8 @@ private:
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
PitchShifter shiftR_;
std::int64_t feedPos_ = 0;
instrument::engine::StretchCursor stretch_;
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
std::vector<AudioSample> primeBuf_;
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
+10 -1
View File
@@ -54,7 +54,16 @@ void VoiceEngine::applyLiveToActive() {
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
// THE read of the note-on-latched commit class, and the only one: a published block outranks
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
// touches none of these three — so a move reaches the next note and no sounding one.
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack;
// Already spline-folded in the block; the snapshot branch folds here so the two agree.
const double lengthFraction =
haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play);
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}
+2 -1
View File
@@ -40,7 +40,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
reasampler_pure_library(sample_map
SOURCES sample_map.cpp
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law
musical_division)
musical_division
PRIVATE period_detect)
# 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)
+29 -4
View File
@@ -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..v14) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v16) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -104,7 +104,7 @@ namespace reasampler::instrument::map {
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// v13 is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
@@ -120,7 +120,7 @@ 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
// v14 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
@@ -129,6 +129,23 @@ namespace reasampler::instrument::map {
// A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record —
// the same revive discipline the v13 hard-flag tails follow, and for the same reason.
//
// v15 is v14 PLUS ONE byte: the master-bus limiter's enable, appended after the Hold
// division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed,
// which is also the field's product default, so a project saved before the limiter existed
// reopens with the limiter off and sounding identical. It carries the Hold's revive
// discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte
// would otherwise reset the record the Hold's own revive just preserved.
//
// v16 (CURRENT WRITE FORMAT) is v15 PLUS TWO 8-byte LE doubles, appended after the limiter
// byte: the playback RATE as a ratio, then the baseline PITCH offset in semitones. A v15-or-
// older blob is a strict prefix and lifts to 1.0 / 0.0 — unity rate and no offset, which is
// what every instance before them played, so it reopens bit-identical. Both are rate-free
// values, so nothing about them is resolved against the project rate. Same revive-and-drain
// discipline as the two tails above. The two wire GUARDS deliberately differ, and
// readRateAndPitchOffset owns why: the offset is range-checked here because nothing downstream
// bounds it, while the rate is only checked for usability because its range belongs to the
// engine's own clamp.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
@@ -160,7 +177,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division
inline constexpr std::uint32_t kParamsPayloadVersion = 16; // v15 + Rate and the pitch offset
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -192,6 +209,14 @@ inline constexpr std::uint32_t kParamsSplineVersion = 13;
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;
// v14 + the master-bus limiter enable; the appended byte branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsLimiterVersion = 15;
// v15 + the playback rate and the baseline pitch offset; the appended pair branches on THIS,
// never on kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsRateVersion = 16;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+69 -9
View File
@@ -8,6 +8,7 @@
#include <cmath> // std::isfinite (wire-value validation)
#include <utility> // std::move
#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound)
#include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
@@ -221,24 +222,76 @@ 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.
// THE shared ending for every appended tail past the hard flags: revive, then DRAIN. Both
// halves are load-bearing and neither is optional.
//
// Revive, because these tails reach no audio path — a blob truncated inside one must cost
// that field alone and not reset the whole record that parsed cleanly ahead of it. An r.ok
// already false on entry (an earlier, unrelated field genuinely truncated) is left alone;
// that failure is not this tail's to forgive.
//
// Drain, because a FAILED read does not advance the cursor. The bytes it rejected are still
// sitting there for the NEXT tail to consume as its own — a truncated Hold whose two
// surviving exponent bytes arrive at the limiter byte reads back as ENABLED. Reviving without
// draining does not degrade to absent; it fabricates. Every tail added after this one must
// end here too.
//
// Returns true when the caller must abandon its field.
bool reviveTruncatedTail(ByteReader& r, bool enteredOk) {
if (r.ok) return false;
if (enteredOk) r.ok = true;
drainUnaligned(r);
return true;
}
// Read the v14 bake Hold.
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;
}
if (reviveTruncatedTail(r, enteredOk)) 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 v15 limiter enable. Bypassed is what a truncation means and what the field already
// holds, so a missing byte costs nothing beyond the enable itself.
void readLimiterEnable(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const std::uint8_t flag = r.u8();
if (reviveTruncatedTail(r, enteredOk)) return;
p.limiterEnabled = (flag != 0);
}
// Read the v16 rate + pitch-offset pair. A truncation, or either value unusable, leaves the
// neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16
// blob means and what every instance before them played.
//
// The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather
// than merely admitted: the stretcher owns its range, so a second copy of the bounds here could
// disagree with it — but a value that only playback clamped would re-serialize out of range and
// leave the stored value disagreeing with the needle, and with the host normalization once the
// instrument reports parameters. Finiteness stays a separate test in front of it, because
// corruption is not an out-of-range value: an infinite rate degrades to the neutral, where a
// merely-too-fast one clamps to the bound. The offset gets a real range test instead, because
// nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild
// exponent there is UB on the per-sample path.
void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const double rate = bitsToDouble(r.u64());
const double offset = bitsToDouble(r.u64());
if (reviveTruncatedTail(r, enteredOk)) return;
if (std::isfinite(rate)) p.play.playRate = engine::clampStretchRate(rate);
// The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and
// the velocity->pitch curve speak (play_params.h), reached directly rather than through the
// deck's alias of it.
if (std::isfinite(offset) && offset >= -kVelocityPitchRangeSemitones &&
offset <= kVelocityPitchRangeSemitones) {
p.play.pitchOffsetSemitones = offset;
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// 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
@@ -475,6 +528,11 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
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()));
// v15: the master-bus limiter enable.
out.push_back(p.limiterEnabled ? 1 : 0);
// v16: the playback rate (a ratio) and the baseline pitch offset (semitones), both rate-free.
putLE(out, doubleToBits(pp.playRate));
putLE(out, doubleToBits(pp.pitchOffsetSemitones));
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -529,6 +587,8 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readHardFlags(r, p.play.pitchVelocityCurve);
}
if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p);
if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p);
if (pv >= kParamsRateVersion) readRateAndPitchOffset(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+1 -1
View File
@@ -5,7 +5,7 @@
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
// serialize/deserializeComponentState; nothing outside the codec calls these.
//
// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its
// The format ladder (payload v1..v16) is documented in component_state_io.h, which stays its
// one home. EVERY wire format is FROZEN.
#include <cstdint>
+17
View File
@@ -5,12 +5,25 @@
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
namespace reasampler::instrument::map {
using instrument::engine::VelocityCurve;
// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole
// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage
// time through it, so a stage time can never land on a different frame depending on the writer.
// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0.
inline std::int64_t secondsToFrames(double seconds, double sampleRate) {
if (!(sampleRate > 0.0)) return 0;
double f = seconds * sampleRate;
if (!(f > 0.0)) return 0; // also catches NaN
return static_cast<std::int64_t>(f + 0.5);
}
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
@@ -74,6 +87,10 @@ struct PlaySeconds {
TriggerParams trigger; // Trigger play span (%-length)
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
// Rate and the baseline pitch offset are both rate-FREE (a ratio and a semitone count), so
// they carry through resolvePlay untouched; play_params.h owns what each one means.
double playRate = 1.0;
double pitchOffsetSemitones = 0.0;
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
+21 -5
View File
@@ -3,6 +3,8 @@
#include "core/instrument/map/sample_map.h"
#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period
#include <algorithm> // std::remove_if
#include <cassert> // assert
#include <utility> // std::move
@@ -102,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
}
}
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
@@ -207,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// carries through untouched, already a fraction.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
AhdParams a;
@@ -235,6 +239,8 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.trigger = stored.trigger; // fraction, unchanged
out.trigAhd = resolveAhd(stored.trigAhd);
out.pitchEngine = stored.pitchEngine;
out.playRate = stored.playRate; // a ratio, rate-free
out.pitchOffsetSemitones = stored.pitchOffsetSemitones; // semitones, rate-free
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
@@ -325,6 +331,16 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(resolved.play, data.sampleRate);
// The one place Preserve's source period is computed: the load, off the audio thread.
// Channel 0 only — a stereo pair's two channels share a fundamental, and the splice
// schedule is linked across them anyway. The span is the sustain loop where one is long
// enough (periodAnalysisSpan owns that rule) — every input to it commits through a full
// reload, so the cache is re-derived whenever the span it was chosen from moves.
const instrument::engine::AnalysisSpan span = instrument::engine::periodAnalysisSpan(
data.frames.size(), data.loop.start, data.loop.end, data.loop.hasLoop, data.sampleRate);
data.sourcePeriodFrames =
instrument::engine::detectPeriod(data.frames, data.sampleRate, span.from, span.count)
.frames;
return data;
}
+14 -2
View File
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// True when two refs would build the same SampleData: path plus every intrinsic
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
// what that sample was decoded from; comparing the fields at the call site instead would go
// stale the first time this struct gains one.
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
@@ -173,7 +180,7 @@ struct InstrumentParams {
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// engines.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
@@ -195,6 +202,11 @@ struct InstrumentParams {
// (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 master-bus limiter's single enable. It sits OUTSIDE PlaySeconds deliberately: it is
// a post-voice-mixer concern the shell applies to the summed output, never a voice
// parameter, so it must not ride into the live block or the SampleData build. Default off
// — a blob predating the field lifts to bypassed and sounds identical.
bool limiterEnabled = false;
};
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
@@ -203,7 +215,7 @@ struct InstrumentParams {
struct ResolvedCapture {
std::string relativePath; // project-relative; the shell resolves + decodes it
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
+120
View File
@@ -0,0 +1,120 @@
# src/core/instrument/param — the VST3 parameter surface's pure half
## Scope
What the instrument tells a VST3 host about its automatable parameters, with no VST3 type
anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the
plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), the one formatter per
unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge
decision. The VST3 shell (`shell/instrument/instrument_params`) adapts these onto
`Steinberg::Vst::Parameter`; it decides nothing.
A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the
parameter list is a function of `deckParamCommit` and the value binding, never the reverse.
**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
whose control has no home would no-op silently in both directions on the host path with nothing
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.
## Invariants
### The id table is FOREVER-FROZEN
`param_id.h`'s header states the rule in full and is its one home. It sits on the same footing
as the extension's `"STABLE_FOREVER_STRING"` command ids, the two VST3 class UIDs
(`core/wire/reasampler_uid.h`) and the params-payload field order
(`map/component_state_io.h`) — the fourth member of that family, not a new kind of rule.
**The table carries every assigned number, including numbers not issued today.** Membership of
the parameter list is `isExposed`'s answer, not the table's. A row whose control is currently
`Reload`-tier keeps its number reserved: the day that control gains a live path it is exposed
under the number already written beside it, and no other id moves. That is what the
block-and-step scheme buys, and it is why a refusal to promote a control is cheap.
### The list follows the predicate; the predicate is never bent to fill the list
A control is an exposed parameter **iff** `deckParamCommit` classifies it `Live` or
`NoteOnLatched`. There is no second membership table and no per-control exception. Adding a
parameter means giving a control a live path in `deck_groups`, at which point it qualifies by
the same rule that excluded it.
### `toPlain` is a READ-side mapping and changes no stored value
Reporting Hz / Q / drive depth for the filter's four means **calling** `filter_params`' frozen
laws, never replacing them: those four persist as normalized doubles in payload v9, so their
laws are already wire-frozen. The same holds for `master_gain`'s dB sweep and `curve_law`'s
exponent travel. Every law here is called; none is restated.
### ONE formatter per unit category, two callers
`param_format` returns the DIGITS of a plain value. The editor's knob label renders those digits
plus its own static chrome (the unit suffix, a curve dial's `^`); the host receives the same
digits from `getParamStringByValue` and the unit string from `ParameterInfo::units`. There is no
second implementation on either side — that is why `formatEnvTimeMs` and `formatMasterGainLabel`
no longer exist.
## Modules
- `param_id` — the frozen `ParamId` constants, the `ParamRow` table (id, `DeckParam`, `IUnitInfo`
unit, title, shortTitle) in ascending id, `isExposed`, and the derived `exposedParams()`.
Ascending id IS the presentation order, so identity order and presentation order agree by
construction rather than by maintenance.
- `param_units``UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
pair, and the defaults read off a default-constructed `PlaySeconds`.
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`.
- `param_live` — a host parameter write, BOTH sides of the model/audio split: `applyLiveParam`
patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue
the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay`
allocates), and `writeHostParam` lands the same write in the stored parameter set. One value
map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is
pinned by an exhaustive equivalence test between them over every exposed control. The routing
switch carries **no `default:`** — a control promoted into the list without a route fails to
compile, which the call site's discarded return value would otherwise hide.
- `param_merge` — the audio thread's block-boundary merge DECISION, with no atomic and no host
type in it: which held automation points still outrank the model, which the model has caught up
on and are released, and whether an arriving point moves anything at all. It is the testable
half of the AUTHORITY MODEL stated in `shell/instrument/CLAUDE.md`, and the reason both of that
model's failure modes now have a test rather than a reviewer.
## Gotchas
- **`defaultNormalized` is COMPUTED, never a literal.** It is `toNormalized(defaultPlain)` for
every tapered control, so a host's reset-to-default and the editor's double-click land on the
same value. The filter's four are the one exception and for the opposite reason: their stored
value already IS the normalized one, so their default normalized value is that double verbatim
and no taper participates in the reset path at all.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
- **Both the host's read (`toPlain`) and write (`hostStoredFromNorm`) paths for a curve exponent
skip `curve_law`'s knob detent, and that is deliberate** (Daniel, 2026-08-02: continuous ranges
stay continuous at the host boundary). The detent is a DRAG affordance only — a drag grid
delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
step snaps to it — and a host lane has no grid. `curveFromKnobNorm` already answers exactly
`1.0` at norm `0.5`, so skipping the detent costs nothing in reachability from the host. The
dial-drag path (`ui::storedFromNorm`/its snap) is the one place the detented map still applies,
because that is where the snap earns its place. `test_param_live`'s
`testTheHostSkipsTheCurveDetentAndNothingElse` pins the write half; `test_param_format`'s
`testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor` pins the read half.
- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared 60…+24 range
on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The
editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the
`Decibels` + non-finite test) — "-inf" rather than "-infdB", because there is no decibel value
there. The host has no such hook and will render `ParameterInfo::units` beside it, so this is a
deliberate ONE-VALUE break in the "editor digits + chrome == host digits + units" invariant
stated above.
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
three are held to digit-for-digit string equality like everything else. Morph's path is
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
are genuinely different inputs — worth one integer percent at a value landing on a display
rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
value rather than to the string.
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
model took, so the host never holds the rejected value — the sweep skips the clamped steps for
that reason rather than loosening its comparison.
+38
View File
@@ -0,0 +1,38 @@
# The frozen id table and the derived exposed set. Links deck_groups alone: the exposed set IS
# deckParamCommit's answer, and identity needs nothing else.
reasampler_pure_library(param_id SOURCES param_id.cpp LINK PUBLIC deck_groups)
reasampler_test(param_id LINK param_id)
# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field
# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the
# filter's four and the gain report through, CALLED rather than restated. filter_params rather
# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the
# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer
# of param_format which root CLAUDE.md's bake invariant forbids.
reasampler_pure_library(param_units
SOURCES param_units.cpp
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
# sample_map for the test alone: the host-vs-editor default agreement reads the two instance
# scalars where they LIVE, and one of them is a field of InstrumentParams.
reasampler_test(param_units LINK param_units param_id sample_map)
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
# lives, on InstrumentParams.
reasampler_test(param_format LINK param_format param_id sample_map)
# The host write, both sides of the model/audio split: the live block patched in place and the
# stored parameter set written, through one value map (param_units'). No engine the block is a
# value, not a thing the voice owns.
reasampler_pure_library(param_live
SOURCES param_live.cpp
LINK PUBLIC deck_values live_params param_units)
# sample_map for the test alone: the equivalence assertion drives the MODEL path
# (writeHostParam -> resolvePlay -> foldLive) as its reference.
reasampler_test(param_live LINK param_live param_id sample_map)
# The block-boundary merge decision the automation hold's authority lifetime, with no atomic
# and no host type in it.
reasampler_pure_library(param_merge SOURCES param_merge.cpp LINK PUBLIC param_live)
reasampler_test(param_merge LINK param_merge param_id param_units sample_map)
@@ -0,0 +1,76 @@
// param_format.cpp — see param_format.h.
#include "core/instrument/param/param_format.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <limits>
namespace reasampler::instrument::param {
void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len) {
if (!buf || len == 0) return;
switch (kind) {
case UnitKind::Time:
// Never switches to seconds, so the ceiling reads 10000 and not 10 — units is one
// static string per parameter and cannot change with magnitude. Sub-10 ms keeps a
// decimal so a short attack is not rounded to a bare "0".
std::snprintf(buf, len, plain < 10.0 ? "%.1f" : "%.0f", plain);
return;
case UnitKind::Semitones:
std::snprintf(buf, len, "%+.1f", plain);
return;
case UnitKind::PercentUnipolar:
case UnitKind::PercentKeyTrack:
std::snprintf(buf, len, "%.0f", plain);
return;
case UnitKind::PercentBipolar:
std::snprintf(buf, len, "%+.0f", plain);
return;
case UnitKind::PercentRate:
// One decimal, not integer percent: the snap grid is whole semitones and those do
// not land on integer percent (+1 st = 105.946 %), so an integer display would print
// a snapped position as a value the snap cannot produce.
std::snprintf(buf, len, "%.1f", plain);
return;
case UnitKind::Decibels:
if (!std::isfinite(plain)) { std::snprintf(buf, len, "-inf"); return; }
std::snprintf(buf, len, "%+.1f", plain);
return;
case UnitKind::Hertz:
// The "k" abbreviation is RETIRED: units is one static string per parameter, so a
// magnitude-switching unit is not expressible, and keeping "12.8k" in the editor
// alone would be exactly the host/editor divergence one formatter exists to forbid.
std::snprintf(buf, len, "%.0f", plain);
return;
case UnitKind::Dimensionless:
std::snprintf(buf, len, "%.2f", plain);
return;
}
buf[0] = '\0';
}
void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len) {
formatPlain(unitKindFor(deck), plain, buf, len);
}
bool parsePlain(UnitKind kind, const char* text, double& plain) {
if (!text) return false;
if (kind == UnitKind::Decibels) {
// The one non-numeric string any formatter emits, so the one the parser must recognise.
for (const char* p = text; *p; ++p) {
if (*p == 'i' && p[1] == 'n' && p[2] == 'f') {
plain = -std::numeric_limits<double>::infinity();
return true;
}
}
}
char* end = nullptr;
const double value = std::strtod(text, &end);
if (end == text) return false;
plain = value;
return true;
}
} // namespace reasampler::instrument::param
+26
View File
@@ -0,0 +1,26 @@
// param_format.h — ONE formatter per unit category, and the editor and the host are both its
// callers. It returns the DIGITS of a plain value: no embedded unit, no magnitude-switched unit,
// no width-conditional abbreviation. The editor's knob label adds its own static chrome (the
// unit suffix, a curve dial's "^"); the host receives these digits from getParamStringByValue and
// the unit from ParameterInfo::units. There is no second implementation on either side — the two
// surfaces disagreeing about what a value reads as is a defect class this closes structurally.
#pragma once
#include <cstddef>
#include "core/instrument/param/param_units.h"
namespace reasampler::instrument::param {
// Writes at most `len` bytes including the terminator.
void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len);
// The digits a control reads as at `plain`. Convenience over formatPlain for the common case.
void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len);
// Digits -> plain, for getParamValueByString. False when the text carries no number; a trailing
// unit suffix is tolerated, since a user retyping a displayed value keeps it.
bool parsePlain(UnitKind kind, const char* text, double& plain);
} // namespace reasampler::instrument::param
+105
View File
@@ -0,0 +1,105 @@
// param_id.cpp — see param_id.h. The table is written out rather than derived: a derived id is
// a function of something else, and every such input then has to never change. A literal table
// makes the freeze visible AT THE POINT OF CHANGE — it cannot be renumbered by accident, because
// renumbering it means editing the numbers.
#include "core/instrument/param/param_id.h"
namespace reasampler::instrument::param {
namespace {
// Within a block the order is the group's own semantic order — envelope stages in temporal
// order, filter cells in solve order — seeded ONCE here and never re-seeded from cellIds. A
// group's cell order is exactly as mobile as the deck's row order is; this sequence is a
// property of THIS table, which is what lets DeckParam keep its "runtime-only, free to change"
// licence.
const std::vector<ParamRow>& table() {
static const std::vector<ParamRow> kTable = {
{kParamKeyTrackPitch, DeckParam::kKeyTrack, kUnitPitch, "Key Track", "KeyTrk"},
{kParamRate, DeckParam::kRate, kUnitPitch, "Playback Rate", "Rate"},
{kParamPitchOffset, DeckParam::kPitch, kUnitPitch, "Pitch Offset", "Pitch"},
{kParamPitchEnvAttack, DeckParam::kPitchEnvAttack, kUnitPitchEnv, "Pitch Env Attack", "PEnvA"},
{kParamPitchEnvAttackCurve, DeckParam::kPitchEnvAttackCurve, kUnitPitchEnv, "Pitch Env Attack Curve", "PEnvAC"},
{kParamPitchEnvHold, DeckParam::kPitchEnvHold, kUnitPitchEnv, "Pitch Env Hold", "PEnvH"},
{kParamPitchEnvDecay, DeckParam::kPitchEnvDecay, kUnitPitchEnv, "Pitch Env Decay", "PEnvD"},
{kParamPitchEnvDecayCurve, DeckParam::kPitchEnvDecayCurve, kUnitPitchEnv, "Pitch Env Decay Curve", "PEnvDC"},
{kParamPitchEnvDepth, DeckParam::kPitchEnvDepth, kUnitPitchEnv, "Pitch Env Depth", "PEnvDp"},
{kParamFilterMorph, DeckParam::kFilterMorph, kUnitFilter, "Filter Morph", "Morph"},
{kParamFilterCutoff, DeckParam::kFilterCutoff, kUnitFilter, "Filter Cutoff", "Cutoff"},
{kParamFilterQ, DeckParam::kFilterQ, kUnitFilter, "Filter Q", "Q"},
{kParamFilterDrive, DeckParam::kFilterDrive, kUnitFilter, "Filter Drive", "Drive"},
{kParamFilterModAmount, DeckParam::kFilterModAmt, kUnitFilter, "Filter Env Amount", "FEnvAmt"},
{kParamFilterVelAmount, DeckParam::kFilterVel, kUnitFilter, "Filter Vel Amount", "FVelAmt"},
{kParamKeyTrackFilter, DeckParam::kFilterKeyTrack, kUnitFilter, "Filter Key Track", "FKeyTrk"},
{kParamFilterEnvAttack, DeckParam::kFilterEnvAttack, kUnitFilterEnv, "Filter Env Attack", "FEnvA"},
{kParamFilterEnvAttackCurve, DeckParam::kFilterEnvAttackCurve, kUnitFilterEnv, "Filter Env Attack Curve", "FEnvAC"},
{kParamFilterEnvHold, DeckParam::kFilterEnvHold, kUnitFilterEnv, "Filter Env Hold", "FEnvH"},
{kParamFilterEnvDecay, DeckParam::kFilterEnvDecay, kUnitFilterEnv, "Filter Env Decay", "FEnvD"},
{kParamFilterEnvDecayCurve, DeckParam::kFilterEnvDecayCurve, kUnitFilterEnv, "Filter Env Decay Curve", "FEnvDC"},
{kParamFilterEnvSustain, DeckParam::kFilterEnvSustain, kUnitFilterEnv, "Filter Env Sustain", "FEnvS"},
{kParamFilterEnvRelease, DeckParam::kFilterEnvRelease, kUnitFilterEnv, "Filter Env Release", "FEnvR"},
{kParamFilterEnvReleaseCurve, DeckParam::kFilterEnvReleaseCurve, kUnitFilterEnv, "Filter Env Release Curve", "FEnvRC"},
{kParamFilterTrigAttack, DeckParam::kFilterTrigAttack, kUnitFilterEnv, "Filter Trig Attack", "FTrgA"},
{kParamFilterTrigAttackCurve, DeckParam::kFilterTrigAttackCurve, kUnitFilterEnv, "Filter Trig Attack Curve", "FTrgAC"},
{kParamFilterTrigHold, DeckParam::kFilterTrigHold, kUnitFilterEnv, "Filter Trig Hold", "FTrgH"},
{kParamFilterTrigDecay, DeckParam::kFilterTrigDecay, kUnitFilterEnv, "Filter Trig Decay", "FTrgD"},
{kParamFilterTrigDecayCurve, DeckParam::kFilterTrigDecayCurve, kUnitFilterEnv, "Filter Trig Decay Curve", "FTrgDC"},
{kParamAmpAttack, DeckParam::kAttack, kUnitAmp, "Amp Attack", "AmpA"},
{kParamAmpAttackCurve, DeckParam::kAttackCurve, kUnitAmp, "Amp Attack Curve", "AmpAC"},
{kParamAmpHold, DeckParam::kHold, kUnitAmp, "Amp Hold", "AmpH"},
{kParamAmpDecay, DeckParam::kDecay, kUnitAmp, "Amp Decay", "AmpD"},
{kParamAmpDecayCurve, DeckParam::kDecayCurve, kUnitAmp, "Amp Decay Curve", "AmpDC"},
{kParamAmpSustain, DeckParam::kSustain, kUnitAmp, "Amp Sustain", "AmpS"},
{kParamAmpRelease, DeckParam::kRelease, kUnitAmp, "Amp Release", "AmpR"},
{kParamAmpReleaseCurve, DeckParam::kReleaseCurve, kUnitAmp, "Amp Release Curve", "AmpRC"},
{kParamTriggerLength, DeckParam::kTrigLength, kUnitAmp, "Trigger Length", "TrgLen"},
{kParamAmpTrigAttack, DeckParam::kTrigAttack, kUnitAmp, "Amp Trig Attack", "ATrgA"},
{kParamAmpTrigAttackCurve, DeckParam::kTrigAttackCurve, kUnitAmp, "Amp Trig Attack Curve", "ATrgAC"},
{kParamAmpTrigHold, DeckParam::kTrigHold, kUnitAmp, "Amp Trig Hold", "ATrgH"},
{kParamAmpTrigDecay, DeckParam::kTrigDecay, kUnitAmp, "Amp Trig Decay", "ATrgD"},
{kParamAmpTrigDecayCurve, DeckParam::kTrigDecayCurve, kUnitAmp, "Amp Trig Decay Curve", "ATrgDC"},
{kParamMasterGain, DeckParam::kMasterGain, kUnitMaster, "Master Gain", "Gain"},
};
return kTable;
}
} // namespace
const std::vector<ParamRow>& paramTable() { return table(); }
bool isExposed(DeckParam deck) {
return ui::deckParamCommit(deck) != ui::LiveCommit::Reload;
}
const std::vector<ParamRow>& exposedParams() {
static const std::vector<ParamRow> kExposed = [] {
std::vector<ParamRow> rows;
for (const ParamRow& row : table()) {
if (isExposed(row.deck)) rows.push_back(row);
}
return rows;
}();
return kExposed;
}
const ParamRow* exposedRowFor(ParamId id) {
for (const ParamRow& row : exposedParams()) {
if (row.id == id) return &row;
}
return nullptr;
}
ParamId paramIdFor(DeckParam deck) {
for (const ParamRow& row : table()) {
if (row.deck == deck) return row.id;
}
return 0;
}
} // namespace reasampler::instrument::param
+135
View File
@@ -0,0 +1,135 @@
// param_id.h — the VST3 parameter identity space: the frozen id table, its DeckParam binding,
// the deck-group units, and the exposed set DERIVED from the commit predicate. Pure: no VST3
// type appears here, so the whole contract is provable without a host.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/deck_groups.h" // DeckParam + deckParamCommit (the predicate)
namespace reasampler::instrument::param {
using ui::DeckParam;
// A host records this number into automation lanes inside project files this repo does not own
// and cannot migrate.
//
// THE PARAMETER-ID TABLE IS FOREVER-FROZEN, on the same footing as the extension's
// "STABLE_FOREVER_STRING" command ids, the two VST3 class UIDs (core/wire/reasampler_uid.h) and
// the params-payload field order (map/component_state_io.h):
// - No id is ever reassigned, reused or re-pointed. A control whose meaning genuinely changes
// takes a NEW id; the old one is marked dead here and never re-issued.
// - No exposed parameter's normalization ever changes — not its taper, not either range
// endpoint, not its stepCount. The normalization IS the meaning of every recorded point.
// - A parameter's meaning never depends on a mode. The Gate-face and Trigger-face stage times
// are separate stored fields and take separate ids.
// - A new control takes the next free slot inside its own group's block, never the next number
// at the end of the table.
// Display strings, titles and precision are NOT frozen — they are what a user reads, not what a
// lane stores.
using ParamId = std::uint32_t;
// Blocks of 100 per deck group in SIGNAL-FLOW order, steps of 10 within a block, a curve dial at
// its outer knob's id + 1. Blocks start at 1000 so the first legitimate id is not also the most
// likely bug value (a default-initialised ParamId). Nine free slots between neighbours put a
// control added later numerically beside its siblings instead of at the end of the table.
// 1500-1599 (VELOCITY) and 1600-1699 (VOICE) are RESERVED and empty — a control either group
// ever gains lands in its own range rather than in whatever range happened to be free.
enum : ParamId {
kParamKeyTrackPitch = 1000,
kParamRate = 1010,
kParamPitchOffset = 1020,
kParamPitchEnvAttack = 1100,
kParamPitchEnvAttackCurve= 1101,
kParamPitchEnvHold = 1110,
kParamPitchEnvDecay = 1120,
kParamPitchEnvDecayCurve = 1121,
kParamPitchEnvDepth = 1130,
kParamFilterMorph = 1200,
kParamFilterCutoff = 1210,
kParamFilterQ = 1220,
kParamFilterDrive = 1230,
kParamFilterModAmount = 1240,
kParamFilterVelAmount = 1250,
kParamKeyTrackFilter = 1260,
kParamFilterEnvAttack = 1300,
kParamFilterEnvAttackCurve = 1301,
kParamFilterEnvHold = 1310,
kParamFilterEnvDecay = 1320,
kParamFilterEnvDecayCurve = 1321,
kParamFilterEnvSustain = 1330,
kParamFilterEnvRelease = 1340,
kParamFilterEnvReleaseCurve = 1341,
kParamFilterTrigAttack = 1350,
kParamFilterTrigAttackCurve = 1351,
kParamFilterTrigHold = 1360,
kParamFilterTrigDecay = 1370,
kParamFilterTrigDecayCurve = 1371,
kParamAmpAttack = 1400,
kParamAmpAttackCurve = 1401,
kParamAmpHold = 1410,
kParamAmpDecay = 1420,
kParamAmpDecayCurve = 1421,
kParamAmpSustain = 1430,
kParamAmpRelease = 1440,
kParamAmpReleaseCurve = 1441,
kParamTriggerLength = 1450,
kParamAmpTrigAttack = 1460,
kParamAmpTrigAttackCurve = 1461,
kParamAmpTrigHold = 1470,
kParamAmpTrigDecay = 1480,
kParamAmpTrigDecayCurve = 1481,
kParamMasterGain = 1700,
};
// IUnitInfo units, one per deck group that carries an exposed parameter. 0 is the SDK's root
// unit, so these start at 1. Softer than the id freeze but user-facing and cached by some hosts.
enum : std::int32_t {
kUnitRoot = 0,
kUnitPitch = 1,
kUnitPitchEnv = 2,
kUnitFilter = 3,
kUnitFilterEnv = 4,
kUnitAmp = 5,
kUnitMaster = 6,
};
struct ParamRow {
ParamId id;
DeckParam deck;
std::int32_t unit;
const char* title; // survives truncation
const char* shortTitle; // distinct, for a narrow host column
};
// The WHOLE frozen assignment, in ascending id — which is also the presentation order, so
// identity order and presentation order agree by construction rather than by maintenance.
// Membership of the parameter list is NOT decided here: a row is issued to the host only if
// isExposed() says so. A row whose control is not exposed today keeps its number reserved for
// the day that control gains a live path, which is what the block-and-step scheme buys.
const std::vector<ParamRow>& paramTable();
// A control is an exposed VST3 parameter IF AND ONLY IF its commit class is Live or
// NoteOnLatched. Derived from deckParamCommit, never hand-maintained: the list follows the
// predicate, and the predicate is never bent to fill the list.
bool isExposed(DeckParam deck);
// paramTable() filtered by isExposed, still in ascending id. This is exactly what the host is
// told, in the order it is told.
const std::vector<ParamRow>& exposedParams();
// The row for an id, or null when the id is unknown or its control is not exposed.
const ParamRow* exposedRowFor(ParamId id);
// The id a control is numbered as, or 0 when the control has no row at all. Answers for
// unexposed rows too — the number is a property of the table, not of today's membership.
ParamId paramIdFor(DeckParam deck);
} // namespace reasampler::instrument::param
+126
View File
@@ -0,0 +1,126 @@
// param_live.cpp — see param_live.h. ONE exhaustive routing switch and one shared value map;
// every law is called, none is restated.
#include "core/instrument/param/param_live.h"
#include <cstdint>
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
#include "core/instrument/param/param_units.h" // hostStoredFromNorm (the ONE host value map)
#include "core/instrument/ui/deck_values.h" // the field resolvers setDeckParam writes through
namespace reasampler::instrument::param {
using engine::LiveValues;
bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) {
const double stored = hostStoredFromNorm(deck, normalized);
const auto frames = [&](std::int64_t& dst) {
dst = map::secondsToFrames(stored, static_cast<double>(sampleRate));
return true;
};
const auto position = [&](float& dst) { dst = static_cast<float>(stored); return true; };
const auto value = [&](double& dst) { dst = stored; return true; };
// NO `default:` — see the header. A promotion that forgets this file is a compile error.
switch (deck) {
// The fourteen stage times: stored seconds resolved at the BUILT rate.
case DeckParam::kAttack: return frames(block.adsr.attackFrames);
case DeckParam::kHold: return frames(block.adsr.holdFrames);
case DeckParam::kDecay: return frames(block.adsr.decayFrames);
case DeckParam::kRelease: return frames(block.adsr.releaseFrames);
case DeckParam::kTrigAttack: return frames(block.ampAhd.attackFrames);
case DeckParam::kTrigDecay: return frames(block.ampAhd.decayFrames);
case DeckParam::kPitchEnvAttack: return frames(block.pitchEnv.shape.attackFrames);
case DeckParam::kPitchEnvDecay: return frames(block.pitchEnv.shape.decayFrames);
case DeckParam::kFilterEnvAttack: return frames(block.filterEnv.attackFrames);
case DeckParam::kFilterEnvHold: return frames(block.filterEnv.holdFrames);
case DeckParam::kFilterEnvDecay: return frames(block.filterEnv.decayFrames);
case DeckParam::kFilterEnvRelease: return frames(block.filterEnv.releaseFrames);
case DeckParam::kFilterTrigAttack: return frames(block.filterAhd.attackFrames);
case DeckParam::kFilterTrigDecay: return frames(block.filterAhd.decayFrames);
// The filter's four, which store their normalized position as float exactly as the
// parameter set stores it.
case DeckParam::kFilterMorph: return position(block.filterSettings.morphNorm);
case DeckParam::kFilterCutoff: return position(block.filterSettings.cutoffNorm);
case DeckParam::kFilterQ: return position(block.filterSettings.resonanceNorm);
case DeckParam::kFilterDrive: return position(block.filterSettings.driveNorm);
// Everything the block carries verbatim as a double.
case DeckParam::kSustain: return value(block.adsr.sustainLevel);
case DeckParam::kAttackCurve: return value(block.adsr.attackCurve);
case DeckParam::kDecayCurve: return value(block.adsr.decayCurve);
case DeckParam::kReleaseCurve: return value(block.adsr.releaseCurve);
case DeckParam::kTrigHold: return value(block.ampAhd.holdFraction);
case DeckParam::kTrigAttackCurve: return value(block.ampAhd.attackCurve);
case DeckParam::kTrigDecayCurve: return value(block.ampAhd.decayCurve);
case DeckParam::kPitchEnvHold: return value(block.pitchEnv.shape.holdFraction);
case DeckParam::kPitchEnvAttackCurve: return value(block.pitchEnv.shape.attackCurve);
case DeckParam::kPitchEnvDecayCurve: return value(block.pitchEnv.shape.decayCurve);
case DeckParam::kPitchEnvDepth: return value(block.pitchEnv.peakSemitones);
case DeckParam::kFilterEnvSustain: return value(block.filterEnv.sustainLevel);
case DeckParam::kFilterEnvAttackCurve: return value(block.filterEnv.attackCurve);
case DeckParam::kFilterEnvDecayCurve: return value(block.filterEnv.decayCurve);
case DeckParam::kFilterEnvReleaseCurve: return value(block.filterEnv.releaseCurve);
case DeckParam::kFilterTrigHold: return value(block.filterAhd.holdFraction);
case DeckParam::kFilterTrigAttackCurve: return value(block.filterAhd.attackCurve);
case DeckParam::kFilterTrigDecayCurve: return value(block.filterAhd.decayCurve);
case DeckParam::kFilterModAmt: return value(block.filterModAmount);
case DeckParam::kFilterVel: return value(block.filterVelAmount);
case DeckParam::kFilterKeyTrack: return value(block.filterKeyTrack);
case DeckParam::kRate: return value(block.playRate);
case DeckParam::kPitch: return value(block.pitchOffsetSemitones);
case DeckParam::kKeyTrack: return value(block.keyTrack);
// The one control the block does not carry verbatim: what it publishes is the
// SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for the
// same reason the knob is inert in the editor.
case DeckParam::kTrigLength:
if (!block.splineActive) block.lengthFraction = stored;
return true;
// Not carried. Master gain reaches the audio beside the block, as the processor's own
// atomic; the rest are toggles, radios, curve-popup cells and the deck's processor-side
// controls — all Reload- or rebuild-tier, so none of them is an exposed parameter.
case DeckParam::kMasterGain:
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kPitchEnvEnable:
case DeckParam::kFilterEnable:
case DeckParam::kFilterLaw:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
case DeckParam::kFilterVelCurve:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kLimiterEnable:
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount:
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized) {
const double stored = hostStoredFromNorm(deck, normalized);
if (float* f = ui::deckFloatField(deck, play)) {
*f = static_cast<float>(stored);
return true;
}
if (double* d = ui::deckDoubleField(deck, play)) {
*d = stored;
return true;
}
return false;
}
} // namespace reasampler::instrument::param
+41
View File
@@ -0,0 +1,41 @@
// param_live.h — a host parameter write landed on BOTH sides of the model/audio split: into the
// live block in place (RT-safe, for `IParameterChanges`, which the SDK delivers on the audio
// thread where the model path cannot run — `resolvePlay` allocates), and into the stored
// parameter set. One norm -> stored map serves both, so they cannot disagree.
#pragma once
#include "core/instrument/engine/live_params.h"
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the model-side write target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
namespace reasampler::instrument::param {
using ui::DeckParam;
// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental
// beyond the taper's own. Returns false for a control this block does not carry — master gain,
// which reaches the audio as the processor's own atomic, and anything unexposed.
//
// The value laws are NOT restated here: `hostStoredFromNorm` is the same norm -> stored map the
// model-side write below takes, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
// IS new is the routing — which member of the block a control names — and its switch carries no
// `default:`, so a control promoted into the parameter list without a route here fails to COMPILE
// rather than dropping its automation silently at a call site that discards the answer.
//
// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_),
// so a patched stage time lands on exactly the frames the build would have resolved.
bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate);
// The MODEL-side peer: the same host write, landed in the stored parameter set instead. Sharing
// `hostStoredFromNorm` and the field resolvers with the patch above is what makes the equivalence
// test's claim — patch == fold-after-write — a property of one map rather than of two that agree.
// False for a control PlaySeconds does not carry: the two instance scalars (master gain, pitch
// key-track) are written where they live, by the shell.
//
// No `enforceGateUnavailableWhileDrawn` here, unlike `ui::setDeckParam`: every control that can
// flip `splineActive` is a toggle, every toggle is Reload-tier, and no Reload-tier control is
// exposed — so nothing reachable from a host write can open that hole.
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized);
} // namespace reasampler::instrument::param
+25
View File
@@ -0,0 +1,25 @@
// param_merge.cpp — see param_merge.h.
#include "core/instrument/param/param_merge.h"
#include "core/instrument/param/param_live.h"
namespace reasampler::instrument::param {
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
int sampleRate) {
for (std::size_t i = 0; i < count; ++i) {
AutomationSlot& slot = slots[i];
if (!slot.held) continue;
if (slot.folded) {
// Nothing to patch: `block` was read from the model, and the model is what the fold
// wrote this point into. Dropping the hold here is the whole release.
slot.held = false;
slot.folded = false;
continue;
}
applyLiveParam(block, static_cast<DeckParam>(i), slot.norm, sampleRate);
}
}
} // namespace reasampler::instrument::param
+51
View File
@@ -0,0 +1,51 @@
// param_merge.h — the audio thread's block-boundary merge DECISION, with no host type and no
// atomic in it: which held automation points still outrank the model, which the model has caught
// up on and are released, and whether the result is worth republishing. The AUTHORITY MODEL it
// implements is stated in `shell/instrument/CLAUDE.md`; this is its testable half.
#pragma once
#include <cstddef>
#include "core/instrument/engine/live_params.h"
#include "core/instrument/ui/deck_groups.h" // DeckParam (the ordinal space slots are indexed by)
namespace reasampler::instrument::param {
using ui::DeckParam;
// The DeckParam ordinal space. One slot per control, indexed by ordinal, so a lookup is an index
// rather than a search on the audio thread.
inline constexpr std::size_t kDeckParamSlots = static_cast<std::size_t>(DeckParam::kCount);
// One control's automation state as the merge sees it.
struct AutomationSlot {
double norm = 0.0; // the last point this lane delivered
bool held = false; // that point still outranks the model
bool folded = false; // the model has since been rewritten to carry THAT point
};
// Patches every still-held slot over `block`, and RELEASES each slot the model has caught up on.
//
// The release is what BOUNDS a point's authority. A lane outranks a plug-in-side set only while
// it is driving; a value it delivered once, already folded back into the model, outranks nothing.
// Without the release a single automation point would defeat every later state restore, bake
// reset and knob move for the life of the instance — which is the failure this function exists
// to make impossible, and which `test_param_merge` is the test of.
//
// RT-SAFE: no allocation, no lock, no transcendental beyond the tapers' own.
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
int sampleRate);
// Whether a point of `normalized` for a slot in this state actually moves the block. False for a
// point equal to a hold that is still standing — the ordinary read-mode steady state, where a
// host delivers one point per block over a flat lane segment. Republishing there would drive
// `VoiceEngine::applyLiveToActive` over every sounding voice — a `std::pow`, two envelope φ
// re-fits and the filter ramp aims, per voice — for a value that did not move. Once the hold has
// been RELEASED the answer is true again, because some other writer may have moved the model
// since.
inline bool automationPointMoves(const AutomationSlot& slot, double normalized) {
return !slot.held || slot.norm != normalized;
}
} // namespace reasampler::instrument::param
+304
View File
@@ -0,0 +1,304 @@
// param_units.cpp — see param_units.h. Every law here is CALLED, never restated: the stage-time
// and semitone tapers are param_taper's, the curve travel is curve_law's, the filter's four are
// filter_params' own frozen laws, and the dB sweep is master_gain's.
#include "core/instrument/param/param_units.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/master_gain.h"
#include "core/instrument/map/play_seconds.h"
#include "core/instrument/ui/deck_values.h"
#include "core/instrument/ui/param_taper.h"
#include "core/util/clamp01.h"
#include "core/util/curve_law.h"
namespace reasampler::instrument::param {
namespace {
using map::PlaySeconds;
using util::clamp01;
// A whole displayed percent is a different plain full scale per category; these are the two
// non-100 ones, named rather than inlined so the range table and the maps cannot disagree.
constexpr double kPercentFullScale = 100.0;
const double kKeyTrackFullScale = kPercentFullScale * ui::kKeyTrackMax; // 0..200 %
} // namespace
UnitKind unitKindFor(DeckParam deck) {
switch (deck) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigDecay:
return UnitKind::Time;
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return UnitKind::Semitones;
case DeckParam::kSustain:
case DeckParam::kTrigLength:
case DeckParam::kTrigHold:
case DeckParam::kPitchEnvHold:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterMorph:
return UnitKind::PercentUnipolar;
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return UnitKind::PercentKeyTrack;
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return UnitKind::PercentBipolar;
case DeckParam::kRate:
return UnitKind::PercentRate;
case DeckParam::kMasterGain:
return UnitKind::Decibels;
case DeckParam::kFilterCutoff:
return UnitKind::Hertz;
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
// rather than defaulted, and everything with no parameter row at all is listed with
// them: a `default:` here would let a control promoted later inherit Dimensionless
// silently, and an exposed parameter's normalization is frozen on the first shipped
// build — so the wrong answer would be permanent rather than correctable. THIS switch is
// the one that has to be exhaustive; the `default:` arms further down are pre-dispatch
// filters that fall through to it, so they inherit its exhaustiveness rather than
// needing their own.
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kPitchEnvEnable:
case DeckParam::kFilterEnable:
case DeckParam::kFilterLaw:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
case DeckParam::kFilterVelCurve:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kLimiterEnable:
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
return UnitKind::Dimensionless;
// The sentinel, on its own arm: it names no control, so its unit string, plain range and
// toPlain law are all arbitrary. It is here only because the switch is exhaustive, and
// it stays out of the run above so that run reads as a list of real controls.
case DeckParam::kCount:
return UnitKind::Dimensionless;
}
return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning.
}
const char* unitStringFor(DeckParam deck) {
switch (unitKindFor(deck)) {
case UnitKind::Time: return "ms";
case UnitKind::Semitones: return "st";
case UnitKind::PercentUnipolar:
case UnitKind::PercentKeyTrack:
case UnitKind::PercentBipolar:
case UnitKind::PercentRate: return "%";
case UnitKind::Decibels: return "dB";
case UnitKind::Hertz: return "Hz";
case UnitKind::Dimensionless: return "";
}
return "";
}
PlainRange plainRangeFor(DeckParam deck) {
switch (deck) {
case DeckParam::kFilterQ:
return {static_cast<double>(engine::filter::kFilterQMin),
static_cast<double>(engine::filter::kFilterQMax)};
case DeckParam::kFilterDrive:
return {0.0, static_cast<double>(engine::filter::kFilterDriveDepthMax)};
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time: return {0.0, ui::kEnvTimeMaxSeconds * 1000.0};
case UnitKind::Semitones: return {-ui::kPitchDepthMaxSemis, ui::kPitchDepthMaxSemis};
case UnitKind::PercentUnipolar: return {0.0, kPercentFullScale};
case UnitKind::PercentKeyTrack: return {0.0, kKeyTrackFullScale};
case UnitKind::PercentBipolar: return {-kPercentFullScale, kPercentFullScale};
case UnitKind::PercentRate: return {ui::kRateMinRatio * kPercentFullScale,
ui::kRateMaxRatio * kPercentFullScale};
case UnitKind::Decibels: return {engine::kMasterGainMinDb, engine::kMasterGainMaxDb};
case UnitKind::Hertz: return {static_cast<double>(engine::filter::kFilterCutoffMinHz),
static_cast<double>(engine::filter::kFilterCutoffMaxHz)};
case UnitKind::Dimensionless: return {util::kCurveMin, util::kCurveMax};
}
return {};
}
double toPlain(DeckParam deck, double normalized) {
switch (deck) {
case DeckParam::kFilterCutoff:
return static_cast<double>(
engine::filter::filterCutoffHzFromNorm(static_cast<float>(normalized)));
case DeckParam::kFilterQ:
return static_cast<double>(
engine::filter::filterQFromNorm(static_cast<float>(normalized)));
case DeckParam::kFilterDrive:
return static_cast<double>(
engine::filter::filterDriveDepthFromNorm(static_cast<float>(normalized)));
case DeckParam::kMasterGain:
// -inf at norm 0 — true silence, and the one plain value outside the declared range.
return engine::masterGainDbFromNorm(normalized);
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time:
return ui::timeSecondsFromNorm(normalized) * 1000.0;
case UnitKind::Semitones:
return ui::depthSemitonesFromNorm(normalized, ui::kPitchDepthMaxSemis);
case UnitKind::PercentUnipolar:
return clamp01(normalized) * kPercentFullScale;
case UnitKind::PercentKeyTrack:
return clamp01(normalized) * kKeyTrackFullScale;
case UnitKind::PercentBipolar:
return ui::deckBipolarFromNorm(normalized) * kPercentFullScale;
case UnitKind::PercentRate:
return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) *
kPercentFullScale;
case UnitKind::Dimensionless:
// Undetented: a host-facing continuous range stays continuous (Daniel, 2026-08-02) —
// the detent is a drag affordance, not part of the value law. See hostStoredFromNorm.
return util::curveFromKnobNormUndetented(normalized);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above
}
return normalized;
}
double toNormalized(DeckParam deck, double plain) {
switch (deck) {
case DeckParam::kFilterCutoff:
return static_cast<double>(
engine::filter::filterNormFromCutoffHz(static_cast<float>(plain)));
case DeckParam::kFilterQ:
return static_cast<double>(
engine::filter::filterNormFromQ(static_cast<float>(plain)));
case DeckParam::kFilterDrive:
return static_cast<double>(
engine::filter::filterNormFromDriveDepth(static_cast<float>(plain)));
case DeckParam::kMasterGain:
return engine::masterGainNormFromDb(plain);
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time:
return ui::timeNormFromSeconds(plain / 1000.0);
case UnitKind::Semitones:
return ui::depthNormFromSemitones(plain, ui::kPitchDepthMaxSemis);
case UnitKind::PercentUnipolar:
return clamp01(plain / kPercentFullScale);
case UnitKind::PercentKeyTrack:
return clamp01(plain / kKeyTrackFullScale);
case UnitKind::PercentBipolar:
return ui::deckNormFromBipolar(plain / kPercentFullScale);
case UnitKind::PercentRate:
return ui::rateNormFromRatio(plain / kPercentFullScale, ui::kRateMinRatio,
ui::kRateMaxRatio);
case UnitKind::Dimensionless:
return util::knobNormFromCurve(plain);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above
}
return plain;
}
double hostStoredFromNorm(DeckParam deck, double normalized) {
// See the header for why the detent is a drag affordance and not part of the value law.
if (ui::deckParamUnit(deck) == ui::UnitCategory::Exponent) {
return util::curveFromKnobNormUndetented(normalized);
}
return ui::storedFromNorm(deck, normalized);
}
ValueHome valueHomeFor(DeckParam deck) {
PlaySeconds defaults;
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet;
if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) {
return ValueHome::InstanceScalar;
}
return ValueHome::None;
}
bool storesNormalized(DeckParam deck) {
return valueHomeFor(deck) == ValueHome::ParamSetNorm;
}
double defaultPlain(DeckParam deck) {
PlaySeconds defaults;
// The filter's four store the normalized position itself, so their plain default is that
// stored position read THROUGH the law — the law is the display, never the storage.
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return toPlain(deck, static_cast<double>(*stored));
}
// The two instance scalars, whose default is not a field of PlaySeconds.
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
if (deck == DeckParam::kKeyTrack) {
return kKeyTrackDefault * kPercentFullScale;
}
const double* field = ui::deckDoubleField(deck, defaults);
if (!field) return 0.0;
switch (unitKindFor(deck)) {
// Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness
// additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS,
// not in ms. It holds for today's three Time defaults; a new one is a case to re-check.
case UnitKind::Time: return *field * 1000.0; // stored seconds
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
case UnitKind::PercentBipolar: return *field * kPercentFullScale;
case UnitKind::PercentRate: return *field * kPercentFullScale; // stored ratio
case UnitKind::Semitones:
case UnitKind::Dimensionless: return *field; // already the plain unit
case UnitKind::Decibels:
case UnitKind::Hertz: break; // handled above
}
return *field;
}
double defaultNormalized(DeckParam deck) {
PlaySeconds defaults;
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return static_cast<double>(*stored); // verbatim: no taper on the reset path
}
return toNormalized(deck, defaultPlain(deck));
}
} // namespace reasampler::instrument::param
+85
View File
@@ -0,0 +1,85 @@
// param_units.h — the plain-value layer the host reads a parameter through: the unit category,
// the plain range, and the norm <-> plain pair. `toPlain` IS the taper's forward map and
// `toNormalized` its inverse, so the host's normalization, the knob's needle angle and the
// overlay node's position are the SAME function rather than three that agree today.
#pragma once
#include "core/instrument/ui/deck_groups.h" // DeckParam
namespace reasampler::instrument::param {
using ui::DeckParam;
// The eight DISPLAY categories. A category fixes the units string and the digit precision; the
// norm <-> plain LAW is per control, because three of the dimensionless controls (the curve
// exponents, Q, drive) share a display and share no law.
enum class UnitKind {
Time, // ms, 0..10000
Semitones, // st, -24..+24, always signed
PercentUnipolar, // %, 0..100
PercentKeyTrack, // %, 0..200
PercentBipolar, // %, -100..+100, always signed
PercentRate, // %, 50..200, one decimal
Decibels, // dB, -60..+24, always signed; norm 0 reads -inf
Hertz, // Hz, 20..20000
Dimensionless, // no unit, two decimals
};
struct PlainRange {
double min = 0.0;
double max = 1.0;
};
UnitKind unitKindFor(DeckParam deck);
// The units string ParameterInfo carries — "" for the dimensionless category. Carried SEPARATELY
// from the digits, which is the SDK's own convention (RangeParameter::toString prints the number;
// the Parameter constructor takes units as its own argument).
const char* unitStringFor(DeckParam deck);
PlainRange plainRangeFor(DeckParam deck);
// A straight line drawn in a host automation lane is NOT linear in these plain units, and that is
// deliberate: exponential in ms, linear in octaves on cutoff, linear in dB on master gain, a
// linear pitch glide on rate, and slow-near-zero on the two centre-expanded semitone throws. It
// follows from reporting real units over a musically-shaped taper; the remedy for a user who
// wants a literal-units ramp is the host's own curve tools, never a change to the taper.
double toPlain(DeckParam deck, double normalized);
double toNormalized(DeckParam deck, double plain);
// The STORED value a host write of `normalized` lands on — `ui::storedFromNorm` for every
// control except the twelve curve exponents, where the knob law's ±0.01 detent is skipped. That
// detent is a DRAG affordance: a drag grid lands on the identity only by luck, so a band wider
// than one drag step snaps to it. A host lane has no grid and `curveFromKnobNorm` already
// answers exactly 1.0 at norm 0.5, so applying the detent here would not make anything
// reachable — it would flatten a knot-drawn near-neutral exponent to 1.0 on any lane pass.
// BOTH host write paths take this map (the model's `writeHostParam`, the audio thread's
// `applyLiveParam`), which is what keeps them from landing different values in the same block.
double hostStoredFromNorm(DeckParam deck, double normalized);
// WHERE a control's value actually lives. The host's read and write paths branch on this, and
// the exposed set is asserted against it: a control promoted into the list with no home would
// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
enum class ValueHome {
None, // not a scalar control at all — a toggle, a radio, a curve-popup cell
ParamSetNorm, // the filter's four: the stored double IS the normalized position
ParamSet, // every other knob the parameter set carries
InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar
};
ValueHome valueHomeFor(DeckParam deck);
// The filter's four tone controls STORE their normalized position (payload v9), so their default
// normalized value is that stored double verbatim and no taper participates in a host's
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
// replacing them.
bool storesNormalized(DeckParam deck);
// The default, read off a default-constructed PlaySeconds — there is no second table of defaults,
// and no normalized default is ever written as a literal. defaultNormalized is COMPUTED as
// toNormalized(defaultPlain) for every tapered control, which is what makes a host's
// reset-to-default and the editor's double-click land on the same value.
double defaultPlain(DeckParam deck);
double defaultNormalized(DeckParam deck);
} // namespace reasampler::instrument::param
+63 -13
View File
@@ -20,25 +20,34 @@ reasampler_test(capture_browser LINK capture_browser)
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
# header needs it.
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit the meter's bar
# count consumes that answer, so the type is part of this module's surface, not an internal.
reasampler_pure_library(waveform_view
SOURCES waveform_view.cpp
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
# sample_bands is linked directly here because the test exercises the lane metrics that
# waveform_view does not re-export.
LINK PUBLIC editor_geometry peaks sample_bands)
reasampler_test(waveform_view LINK waveform_view sample_bands)
# The loop enable's state machine. Links loop_span for the park bounds the span the user is
# offered and the span the engine accepts stay one definition.
reasampler_pure_library(loop_marks SOURCES loop_marks.cpp LINK PUBLIC loop_span)
reasampler_test(loop_marks LINK loop_marks)
reasampler_pure_library(browser_scroll
SOURCES browser_scroll.cpp
LINK PUBLIC capture_browser sample_chrome)
reasampler_test(browser_scroll LINK browser_scroll)
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
reasampler_pure_library(param_slider
SOURCES param_slider.cpp
LINK PUBLIC editor_geometry param_taper)
reasampler_test(param_slider LINK param_slider)
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
reasampler_test(envelope_overlay LINK envelope_overlay)
reasampler_pure_library(envelope_overlay
SOURCES envelope_overlay.cpp
LINK PUBLIC editor_geometry curve_law param_taper)
# sample_bands is linked for the test only: the tapered-axis legibility assertion is judged at the
# editor's own floor width, read from the allocator rather than copied as a number.
reasampler_test(envelope_overlay LINK envelope_overlay sample_bands)
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
reasampler_test(envelope_edit LINK envelope_edit)
@@ -46,17 +55,37 @@ reasampler_test(envelope_edit LINK envelope_edit)
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
reasampler_test(knob_deck LINK knob_deck)
# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to
# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC
# for LaneSplit the bar count is the SAME resolved decision the waveform's lane split is.
reasampler_pure_library(master_meter
SOURCES master_meter.cpp
LINK PUBLIC editor_geometry sample_bands meter_ballistics)
# waveform_view is linked for the test only: proving the bar count is not a second rule takes
# the real waveformSurface fold, over channel mode x source channel count.
reasampler_test(master_meter LINK master_meter waveform_view)
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
# master_meter is PRIVATE: MASTER's descriptor reserves the meter column's own kMeterColumnW,
# but nothing in deck_groups.h names a meter type, so the edge stops at this TU.
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
# WHICH descriptors the deck carries, and how they resolve to a layout no window-floor budget
# assertion here, so this target needs neither sample_bands nor master_meter.
reasampler_test(deck_groups LINK deck_groups)
# The width-BUDGET half, split out on the same seam PRIVATE master_meter already draws above:
# the deck-fits-the-floor-window assertion needs the band allocator, and the MASTER-reserve
# identity needs the column width the PRIVATE edge on deck_groups does not re-export.
reasampler_test(deck_groups_measured LINK deck_groups sample_bands master_meter)
# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam
# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure
# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter.
reasampler_test(deck_groups_state LINK deck_groups)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free the staged envelopes touch no curve).
@@ -74,9 +103,11 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
# from knob_deck. Links the header-only play_seconds, NOT sample_map: PlaySeconds is all a deck
# knob edits, and sample_map would drag the bank model and the WAV codec in behind it. Same for
# the filter's MorphLaw an enum, so no filter symbol is linked.
# time_stretch carries Rate's range the stretcher's own measured bounds, aliased here rather
# than restated so the knob's ends and the engine's clamp cannot disagree.
reasampler_pure_library(deck_values
SOURCES deck_values.cpp
LINK PUBLIC deck_groups play_seconds envelope_overlay)
LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain time_stretch)
reasampler_test(deck_values LINK deck_values)
# The bake Hold knob's value domain. Links the ladder alone it computes no geometry, so it
@@ -88,3 +119,22 @@ reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_g
# 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.
reasampler_test(curve_popup LINK curve_popup velocity_curve)
# The ONE norm<->value taper and modifier vocabulary every variable control shares. Declared
# last, but it sits at the BOTTOM of this directory's dependency order: param_slider,
# envelope_overlay and deck_values all read it which is exactly why it could not stay inside
# deck_values, which sits above envelope_overlay.
reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law)
# envelope_overlay and sample_bands are linked for the test only: the finest-drag-step assertion
# is judged against the envelope node drag at the editor's own floor width (the sharper of the
# taper's two consumers), read from the allocator/overlay rather than copied as a number.
reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands)
# The staged trace, split from envelope_overlay's vertex model. stroke_aa is the trace-point
# vocabulary, filled in place so the shell's scratch buffer is reused rather than a fresh
# vector returned per paint; curve_law arrives through envelope_overlay but is named here
# because this module evaluates the law rather than merely carrying its exponents.
reasampler_pure_library(curve_tessellate
SOURCES curve_tessellate.cpp
LINK PUBLIC envelope_overlay stroke_aa curve_law)
reasampler_test(curve_tessellate LINK curve_tessellate)
@@ -0,0 +1,65 @@
// curve_tessellate.cpp — see curve_tessellate.h. Pure geometry; no host types.
#include "core/instrument/ui/curve_tessellate.h"
#include <algorithm>
#include <cstdlib>
#include "core/util/curve_law.h"
namespace reasampler::instrument::ui {
using StrokePoint = reasampler::ui::StrokePoint;
namespace {
StrokePoint pt(double x, double y) {
return StrokePoint{static_cast<float>(x), static_cast<float>(y)};
}
// The samples strictly BETWEEN two nodes. The endpoints are the caller's, so a shared node is
// emitted once and the polyline carries no zero-length joint.
void appendInterior(int x0, int y0, int x1, int y1, double exponent,
std::vector<StrokePoint>& out) {
const int span = std::abs(x1 - x0);
if (span < 2 || y1 == y0 || exponent == util::kCurveNeutral) return;
const double dx = static_cast<double>(x1 - x0);
const double dy = static_cast<double>(y1 - y0);
for (int i = 1; i < span; ++i) {
// phi is exact at every column, so x lands on the integer column and the last interior
// sample is one column short of the end node.
const double phi = static_cast<double>(i) / static_cast<double>(span);
out.push_back(pt(static_cast<double>(x0) + dx * phi,
static_cast<double>(y0) + dy * util::curveMap(phi, exponent)));
}
}
} // namespace
double segmentCurve(const StageEnvelope& env, EnvNode endNode) {
switch (endNode) {
case EnvNode::AttackEnd: return env.attackCurve;
case EnvNode::DecayEnd: return env.decayCurve;
case EnvNode::ReleaseEnd: return env.releaseCurve;
default: return util::kCurveNeutral;
}
}
void buildEnvelopeTrace(const std::vector<EnvVertex>& poly, const StageEnvelope& env, int xLo,
int xHi, std::vector<StrokePoint>& out) {
out.clear();
bool started = false;
int px = 0;
int py = 0;
for (const EnvVertex& v : poly) {
if (v.knot) continue;
const int x = std::max(xLo, std::min(xHi, v.x));
if (started) appendInterior(px, py, x, v.y, segmentCurve(env, v.node), out);
out.push_back(pt(x, v.y));
started = true;
px = x;
py = v.y;
}
}
} // namespace reasampler::instrument::ui
+34
View File
@@ -0,0 +1,34 @@
// curve_tessellate.h — the staged envelope's TRACE: envelope_overlay's node vertices joined by
// the curve each stage's exponent defines. Split from that module on the axis the two already
// have — envelope_overlay decides where a node LANDS, this strokes the span BETWEEN two of
// them over phi, so a re-scaled time axis changes nothing here.
#pragma once
#include <vector>
#include "core/instrument/ui/envelope_overlay.h"
#include "core/ui/stroke_aa.h" // StrokePoint — the stroker's own vertex type
namespace reasampler::instrument::ui {
// The exponent governing the segment that ENDS at `node`: attack, decay and release are the
// three sloped stages. Every other node ends a plateau, whose straightness comes from its own
// zero level span rather than from an exponent, so neutral is returned and no caller needs a
// second rule to recognize one.
double segmentCurve(const StageEnvelope& env, EnvNode endNode);
// Replaces `out` with the polyline the shell strokes. Knot vertices are handles, not line
// vertices, and are skipped; node vertices keep their exact INTEGER coordinates (clamped to
// [xLo, xHi]) because those are the positions their draggable handles are drawn at. Only the
// interior samples are sub-pixel, one per pixel column, which is what makes the density follow
// the canvas width instead of a fixed count.
//
// A segment's level runs start + (end - start) * curveMap(phi, exponent) — the composition
// envelopes.h's four evaluators use, so the trace cannot diverge from the sound. A neutral
// exponent or a zero level span emits the two endpoints and nothing between: the straight
// stroke, vertex for vertex.
void buildEnvelopeTrace(const std::vector<EnvVertex>& poly, const StageEnvelope& env, int xLo,
int xHi, std::vector<reasampler::ui::StrokePoint>& out);
} // namespace reasampler::instrument::ui
+79 -22
View File
@@ -4,6 +4,8 @@
#include <utility>
#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs)
namespace reasampler::instrument::ui {
namespace {
@@ -11,8 +13,9 @@ int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row the ceiling is PITCH ENV's, whose caption row lands exactly on
// its four-cell knob row at 23. Raising it reflows the deck's first row.
// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
// the CONTOUR row, which has 152px of slack, not the SOUND row.
constexpr int kEnvModeSegW = 23;
} // namespace
@@ -23,11 +26,16 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
const bool trigger = (playMode == PlayMode::Trigger);
std::vector<DeckGroupDesc> out;
{
// PITCH/RATE. The three cells make the knob row 180, which is what the group measures
// from; the caption row (caption + gap + two 48px segments) must stay under it, so the
// caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob
// row and the group grows past 192. Widening the group is not the answer if the text ever
// outgrows 80: narrow the Varisp|Presrv segments to 44 instead.
DeckGroupDesc pitch;
pitch.id = kGroupPitch;
pitch.captionWidth = 38;
pitch.captionWidth = 70;
pitch.captionToggle = {id(DeckParam::kPitchEngine), 48};
pitch.cellIds = {id(DeckParam::kKeyTrack)};
pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)};
out.push_back(std::move(pitch));
}
{
@@ -58,7 +66,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
id(DeckParam::kFilterModAmt),
id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)};
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
// The morph law rides the caption slack. Moving it back to the knob row costs the
// group 92px and the SOUND row stops fitting its block.
filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44};
out.push_back(std::move(filter));
}
{
@@ -121,15 +131,47 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
out.push_back(std::move(voice));
}
{
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
// dashed placeholder would read as unfinished. It is one cell, not two — a second
// would spend 60 of the layout's whole 82px budget on a control nobody has named.
DeckGroupDesc master;
master.id = kGroupMaster;
master.captionWidth = 46;
master.cellIds = {id(DeckParam::kMasterGain)};
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
master.cellIds = {id(DeckParam::kMasterGain), -1};
// The reserve IS what the interior consumes — read from master_meter rather than
// restated, so the two cannot drift when the column grows into MASTER's banked room.
master.column = {id(DeckParam::kMasterMeter), kMeterColumnW};
out.push_back(std::move(master));
}
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
return out;
}
DeckRow deckRowFor(DeckGroupId group) {
// Every enumerator listed and no default, on the same gate deckParamCommit below relies on.
switch (group) {
case kGroupPitch:
case kGroupFilter:
case kGroupVelocity:
case kGroupVoice:
return DeckRow::Sound;
case kGroupPitchEnv:
case kGroupFilterEnv:
case kGroupAmpEnv:
return DeckRow::Contour;
case kGroupMaster:
return DeckRow::Spanning;
}
// Unreachable for a valid enumerator, and Spanning rather than Sound ON PURPOSE: the
// -Wswitch gate is compiler-dependent, so on a toolchain that does not raise it a dropped
// case arm falls here instead. Sound is what a new group most plausibly IS, which would
// make the fall-through invisible; Spanning is the one row nothing may silently join, so
// the tests' partition count catches it.
return DeckRow::Spanning;
}
CurveTarget curveTargetFor(int controlId) {
switch (static_cast<DeckParam>(controlId)) {
case DeckParam::kAmpVelCurve: return CurveTarget::kAmp;
@@ -157,8 +199,19 @@ DeckParam curveParamFor(DeckParam knob) {
}
}
bool isLiveDeckParam(DeckParam id) {
LiveCommit deckParamCommit(DeckParam id) {
switch (id) {
// The note-on-latched controls; the header owns why each one latches.
case DeckParam::kRate:
case DeckParam::kKeyTrack:
case DeckParam::kTrigLength:
return LiveCommit::NoteOnLatched;
// Live by the tier's own definition — one atomic store the audio thread picks up at the
// next block, no bridge read and no re-decode. It reaches the audio beside the live
// block rather than through it, which is why it carries no LiveValues field; that is a
// question of ROUTE, and this predicate answers TIER.
case DeckParam::kMasterGain:
case DeckParam::kPitch:
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
@@ -198,15 +251,14 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
return true;
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
// instead of silently defaulting to non-live. Reasons live in the header.
return LiveCommit::Live;
// Listed rather than defaulted so a newly added control is a COMPILE error here on
// every toolchain — /we4062 on MSVC, -Werror=switch on GCC/Clang, both set on this
// library alone in cmake/reasampler_targets.cmake — instead of silently defaulting
// to non-live. Reasons live in the header.
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
@@ -223,11 +275,15 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain:
case DeckParam::kLimiterEnable:
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
// radios above.
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount: // not a control
return false;
return LiveCommit::Reload;
}
return false; // unreachable for a valid enumerator; silences a warning.
return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning.
}
OverlayEnv overlayEnvForRadio(int radioId) {
@@ -325,17 +381,18 @@ bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
}
}
bool liveCommitFor(LiveDragKind kind, int paramId) {
LiveCommit liveCommitFor(LiveDragKind kind, int paramId) {
switch (kind) {
case LiveDragKind::kDeckKnob:
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
return (paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount))
? deckParamCommit(static_cast<DeckParam>(paramId))
: LiveCommit::Reload;
case LiveDragKind::kEnvNode:
return true;
return LiveCommit::Live;
case LiveDragKind::kOther:
return false;
return LiveCommit::Reload;
}
return false;
return LiveCommit::Reload;
}
} // namespace reasampler::instrument::ui
+56 -14
View File
@@ -32,6 +32,8 @@ enum class DeckParam {
kPitchEnvDecay,
kPitchEnvDepth, // AHD pitch depth in +/- semitones
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
kRate, // playback rate 50..200%, linear in semitones over +/-12
kPitch, // baseline pitch offset, +/-kPitchDepthMaxSemis, centre-expanded
// Filter. The four control positions map through filter_params' own laws; the three
// depths are bipolar and centred at zero.
kFilterEnable, // filter on|off caption toggle
@@ -85,6 +87,11 @@ enum class DeckParam {
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
kLimiterEnable, // master-bus limiter Off | On caption toggle (MASTER group)
// MASTER's two readouts. Neither reaches a parameter: the meter's only gesture is the
// click that clears its latched clip cap, and the bubble is a passive lamp.
kMasterMeter,
kMasterGr,
kCount
};
@@ -101,6 +108,12 @@ enum DeckGroupId {
kGroupMaster,
};
// Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what
// reads it). Membership is a property of the GROUP; width is a property of its descriptor.
// Total over DeckGroupId by an exhaustive switch with no default, so a group added without a
// row cannot silently become Sound.
DeckRow deckRowFor(DeckGroupId group);
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
// one place a control id resolves to a curve target — paint (draw a curve thumbnail, not a
// dial) and hit-test (open a popup, not start a drag) both read this predicate rather than
@@ -121,15 +134,24 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// ordinary knob grab.
DeckParam curveParamFor(DeckParam knob);
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
// rather than through an instrument reload. The line is drawn at continuously-valued playback
// controls, so this is a routing decision at the editor's commit site rather than a property
// of any one knob; moving a control across the line is a change here and nowhere else.
// How an edit to a control reaches the audio — THE one decision, and the home for why each
// control sits where it does. Moving a control across a line is a change here and nowhere else,
// and Γ-W4-T1 derives the host-exposed parameter set from this same predicate, so a
// misclassification here is a mis-declared parameter there.
//
// THE home for why each excluded control is excluded. Three continuous controls are outside
// the live set, plus every discrete toggle and the overlay radios:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
// name a different sound rather than a different setting of one;
// Live — straight to the voices already sounding. Continuously-valued playback
// controls, and the default for anything that is a SETTING of a note rather
// than a fact about it.
// NoteOnLatched — published into the live block like a live control, but read only at
// note-on: a sounding voice keeps the value it started with, the next one
// takes the new one. NOT the reload tier — a swept knob must never trigger a
// WAV re-decode.
// Reload — a bridge read, a re-decode and a fresh engine.
//
// The exclusions from Live, each with its reason:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable,
// the three Staged|Spline mode toggles) name a different sound rather than a different
// setting of one;
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
// the decoded PCM;
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
@@ -141,18 +163,38 @@ DeckParam curveParamFor(DeckParam knob);
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
bool isLiveDeckParam(DeckParam id);
//
// kMasterGain is Live and is the one live control that does NOT ride the live block: it is a
// lock-free atomic on the processor which the audio thread applies as a post-sum multiply. The
// tier answers "does an edit reach the audio without a reload", not "which mechanism carries
// it" — classifying it Reload would have said a gain move re-decodes the WAV, which it never did.
//
// THREE controls are NoteOnLatched: kRate, kKeyTrack and kTrigLength. The exclusions list above
// already gives the latter two their reason — both were Reload until they were promoted so they
// could be automated at all, since a reload per automation point re-decodes the WAV. kRate's
// reason is its own, and is a real feature rather than a plumbing detail: loop points and
// contours both scale with rate, and both are note-on folds — resolveLoop runs once per note-on
// and a contour resolves against the note's own span. A live rate would mean re-folding an
// already-resolved loop and re-mapping a contour mid-note without a discontinuity. kPitch is not
// implicated and is ordinarily Live.
enum class LiveCommit { Live, NoteOnLatched, Reload };
LiveCommit deckParamCommit(DeckParam id);
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
// shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is
// testable without a host.
enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
// reaches the enum); an envelope-node drag is live in either mode, since every stage value it
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
bool liveCommitFor(LiveDragKind kind, int paramId);
// How a drag of `kind` commits. A deck knob answers per deckParamCommit (negative ids are the
// shell's processor-side sentinels and out-of-range ids are not controls, so neither reaches the
// enum); an envelope-node drag is Live in either mode, since every stage value it can reach —
// AHDSR or AHD, on any of the three envelopes — is itself Live.
//
// Live and NoteOnLatched take the SAME route out of the editor — one publish of the live block,
// no reload, no engine rebuild. They differ only in who reads the published value, which is the
// engine's business (live_params.h), so the shell needs the distinction only to know that
// neither reloads.
LiveCommit liveCommitFor(LiveDragKind kind, int paramId);
// Which envelope the waveform overlay draws and edits. Exclusive across the three envelope
// decks, and kNone is a valid resting state — the editor opens there. Transient view state:
+248 -104
View File
@@ -3,9 +3,10 @@
#include "core/instrument/ui/deck_values.h"
#include <algorithm>
#include <cstdio>
#include <cmath>
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
@@ -14,13 +15,6 @@ namespace reasampler::instrument::ui {
using engine::filter::MorphLaw;
using util::clamp01;
namespace {
double secToNorm(double seconds) { return clamp01(seconds / kEnvTimeMaxSeconds); }
double normToSec(double norm) { return clamp01(norm) * kEnvTimeMaxSeconds; }
} // namespace
double deckParamNorm(DeckParam id, const PlaySeconds& play) {
switch (id) {
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
@@ -28,31 +22,36 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds);
case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds);
case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds);
case DeckParam::kRate:
return rateNormFromRatio(play.playRate, kRateMinRatio, kRateMaxRatio);
case DeckParam::kPitch:
return depthNormFromSemitones(play.pitchOffsetSemitones, kPitchDepthMaxSemis);
case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds);
case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds);
case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds);
case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel);
case DeckParam::kRelease: return secToNorm(play.adsr.releaseSeconds);
case DeckParam::kRelease: return timeNormFromSeconds(play.adsr.releaseSeconds);
case DeckParam::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction);
case DeckParam::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
case DeckParam::kTrigAttack: return timeNormFromSeconds(play.trigAhd.attackSeconds);
case DeckParam::kTrigHold: return clamp01(play.trigAhd.holdFraction);
case DeckParam::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
case DeckParam::kTrigDecay: return timeNormFromSeconds(play.trigAhd.decaySeconds);
case DeckParam::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0;
case DeckParam::kPitchEnvAttack: return secToNorm(play.pitchEnv.shape.attackSeconds);
case DeckParam::kPitchEnvAttack:
return timeNormFromSeconds(play.pitchEnv.shape.attackSeconds);
case DeckParam::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
case DeckParam::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
case DeckParam::kPitchEnvDecay:
return timeNormFromSeconds(play.pitchEnv.shape.decaySeconds);
case DeckParam::kPitchEnvAttackCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
case DeckParam::kPitchEnvDecayCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
case DeckParam::kPitchEnvDepth:
// Signed depth centred at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
return depthNormFromSemitones(play.pitchEnv.peakSemitones, kPitchDepthMaxSemis);
// Filter. The four tone controls ARE the module's normalized positions — stored and
// shown as-is, so the knob travel is exactly filter_params' own law.
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
@@ -64,21 +63,24 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
case DeckParam::kFilterKeyTrack: return keyTrackNormFrom(play.filter.keyTrack);
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
case DeckParam::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
case DeckParam::kFilterEnvRelease:
return timeNormFromSeconds(play.filter.env.releaseSeconds);
case DeckParam::kFilterEnvAttackCurve:
return util::knobNormFromCurve(play.filter.env.attackCurve);
case DeckParam::kFilterEnvDecayCurve:
return util::knobNormFromCurve(play.filter.env.decayCurve);
case DeckParam::kFilterEnvReleaseCurve:
return util::knobNormFromCurve(play.filter.env.releaseCurve);
case DeckParam::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
case DeckParam::kFilterTrigAttack:
return timeNormFromSeconds(play.filter.trigEnv.attackSeconds);
case DeckParam::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
case DeckParam::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
case DeckParam::kFilterTrigDecay:
return timeNormFromSeconds(play.filter.trigEnv.decaySeconds);
case DeckParam::kFilterTrigAttackCurve:
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
case DeckParam::kFilterTrigDecayCurve:
@@ -87,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
}
}
double storedFromNorm(DeckParam id, double norm) {
switch (id) {
// The filter's four STORE their normalized position (payload v9), so the identity IS
// their law — deckFloatField's four, and the reason it is a separate resolver.
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
return clamp01(norm);
case DeckParam::kRate:
return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio);
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis);
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return deckBipolarFromNorm(norm);
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return keyTrackFromNorm(norm);
case DeckParam::kTrigLength:
// lengthFraction is (0,1]; a small floor so a zero-length trigger never plays
// nothing.
return (std::max)(0.01, clamp01(norm));
default:
break;
}
// The rest are decided by the display unit alone, which is what makes the fourteen stage
// times and the twelve curve dials one line each rather than twenty-six.
switch (deckParamUnit(id)) {
case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm);
case UnitCategory::Exponent: return util::curveFromKnobNorm(norm);
case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels
// Decibels is master gain, whose stored value is a LINEAR gain the processor owns
// rather than a field of the parameter set; the two enums above are handled by id.
case UnitCategory::Semitones:
case UnitCategory::Decibels:
case UnitCategory::None:
break;
}
return norm;
}
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
switch (id) {
case DeckParam::kPlayMode:
@@ -110,84 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break;
case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case DeckParam::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
// nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
case DeckParam::kTrigAttackCurve:
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigDecayCurve:
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
case DeckParam::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
case DeckParam::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case DeckParam::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
case DeckParam::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
case DeckParam::kFilterLaw:
play.filter.settings.morphLaw =
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
break;
case DeckParam::kFilterMorph:
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterCutoff:
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterQ:
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterDrive:
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterKeyTrack:
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
case DeckParam::kFilterEnvAttack:
play.filter.env.attackSeconds = normToSec(value); break;
case DeckParam::kFilterEnvHold:
play.filter.env.holdSeconds = normToSec(value); break;
case DeckParam::kFilterEnvDecay:
play.filter.env.decaySeconds = normToSec(value); break;
case DeckParam::kFilterEnvSustain:
play.filter.env.sustainLevel = clamp01(value); break;
case DeckParam::kFilterEnvRelease:
play.filter.env.releaseSeconds = normToSec(value); break;
case DeckParam::kFilterEnvAttackCurve:
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvDecayCurve:
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = normToSec(value); break;
case DeckParam::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case DeckParam::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = normToSec(value); break;
case DeckParam::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
default: break;
default:
// Every knob: the one norm -> stored law, into the one field the control names.
// Both halves are shared with the audio thread's live patch (param/param_live), so
// a control cannot take a different taper or land in a different field depending on
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
// neither field and falls through untouched.
if (float* f = deckFloatField(id, play)) {
*f = static_cast<float>(storedFromNorm(id, value));
} else if (double* d = deckDoubleField(id, play)) {
*d = storedFromNorm(id, value);
}
break;
}
// ONE normalization point for every control that can flip splineActive — a mode toggle
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
@@ -197,15 +182,174 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
enforceGateUnavailableWhileDrawn(play);
}
void resetDeckParam(DeckParam id, PlaySeconds& play) {
const PlaySeconds defaults;
setDeckParam(id, play, deckParamNorm(id, defaults), 0);
// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry
// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for
// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture
// and resolves to null.
double* deckDoubleField(DeckParam id, PlaySeconds& p) {
switch (id) {
case DeckParam::kRate: return &p.playRate;
case DeckParam::kPitch: return &p.pitchOffsetSemitones;
case DeckParam::kAttack: return &p.adsr.attackSeconds;
case DeckParam::kHold: return &p.adsr.holdSeconds;
case DeckParam::kDecay: return &p.adsr.decaySeconds;
case DeckParam::kSustain: return &p.adsr.sustainLevel;
case DeckParam::kRelease: return &p.adsr.releaseSeconds;
case DeckParam::kAttackCurve: return &p.adsr.attackCurve;
case DeckParam::kDecayCurve: return &p.adsr.decayCurve;
case DeckParam::kReleaseCurve: return &p.adsr.releaseCurve;
case DeckParam::kTrigLength: return &p.trigger.lengthFraction;
case DeckParam::kTrigAttack: return &p.trigAhd.attackSeconds;
case DeckParam::kTrigHold: return &p.trigAhd.holdFraction;
case DeckParam::kTrigDecay: return &p.trigAhd.decaySeconds;
case DeckParam::kTrigAttackCurve: return &p.trigAhd.attackCurve;
case DeckParam::kTrigDecayCurve: return &p.trigAhd.decayCurve;
case DeckParam::kPitchEnvAttack: return &p.pitchEnv.shape.attackSeconds;
case DeckParam::kPitchEnvHold: return &p.pitchEnv.shape.holdFraction;
case DeckParam::kPitchEnvDecay: return &p.pitchEnv.shape.decaySeconds;
case DeckParam::kPitchEnvAttackCurve: return &p.pitchEnv.shape.attackCurve;
case DeckParam::kPitchEnvDecayCurve: return &p.pitchEnv.shape.decayCurve;
case DeckParam::kPitchEnvDepth: return &p.pitchEnv.peakSemitones;
case DeckParam::kFilterModAmt: return &p.filter.modAmount;
case DeckParam::kFilterVel: return &p.filter.velAmount;
case DeckParam::kFilterKeyTrack: return &p.filter.keyTrack;
case DeckParam::kFilterEnvAttack: return &p.filter.env.attackSeconds;
case DeckParam::kFilterEnvHold: return &p.filter.env.holdSeconds;
case DeckParam::kFilterEnvDecay: return &p.filter.env.decaySeconds;
case DeckParam::kFilterEnvSustain: return &p.filter.env.sustainLevel;
case DeckParam::kFilterEnvRelease: return &p.filter.env.releaseSeconds;
case DeckParam::kFilterEnvAttackCurve: return &p.filter.env.attackCurve;
case DeckParam::kFilterEnvDecayCurve: return &p.filter.env.decayCurve;
case DeckParam::kFilterEnvReleaseCurve: return &p.filter.env.releaseCurve;
case DeckParam::kFilterTrigAttack: return &p.filter.trigEnv.attackSeconds;
case DeckParam::kFilterTrigHold: return &p.filter.trigEnv.holdFraction;
case DeckParam::kFilterTrigDecay: return &p.filter.trigEnv.decaySeconds;
case DeckParam::kFilterTrigAttackCurve: return &p.filter.trigEnv.attackCurve;
case DeckParam::kFilterTrigDecayCurve: return &p.filter.trigEnv.decayCurve;
default: return nullptr;
}
}
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
if (!buf || len == 0) return;
const double ms = seconds * 1000.0;
std::snprintf(buf, len, ms < 10.0 ? "%.1f ms" : "%.0f ms", ms);
// The filter's four tone controls store their NORMALIZED position, as floats, and their law is
// wire-frozen — hence a second resolver rather than a widened first one.
float* deckFloatField(DeckParam id, PlaySeconds& p) {
switch (id) {
case DeckParam::kFilterMorph: return &p.filter.settings.morphNorm;
case DeckParam::kFilterCutoff: return &p.filter.settings.cutoffNorm;
case DeckParam::kFilterQ: return &p.filter.settings.resonanceNorm;
case DeckParam::kFilterDrive: return &p.filter.settings.driveNorm;
default: return nullptr;
}
}
void resetDeckParam(DeckParam id, PlaySeconds& play) {
PlaySeconds defaults;
if (double* dst = deckDoubleField(id, play)) {
*dst = *deckDoubleField(id, defaults);
return;
}
if (float* dst = deckFloatField(id, play)) *dst = *deckFloatField(id, defaults);
}
UnitCategory deckParamUnit(DeckParam id) {
switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigDecay:
return UnitCategory::Milliseconds;
// Rate DISPLAYS as a percent but its unit is the semitone — that is what puts an octave
// and a fifth under Shift, which a whole-percent snap could not reach.
case DeckParam::kRate:
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return UnitCategory::Semitones;
// The filter's four tone controls read out in Hz / Q / drive depth but snap in whole
// percent of the normalized position they STORE — display and snap are independent axes.
case DeckParam::kSustain:
case DeckParam::kTrigLength:
case DeckParam::kTrigHold:
case DeckParam::kPitchEnvHold:
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterTrigHold:
return UnitCategory::Percent;
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
return UnitCategory::Exponent;
case DeckParam::kMasterGain:
return UnitCategory::Decibels;
default:
// Toggles, radios, the curve-popup cells, and the already-integer voice count.
return UnitCategory::None;
}
}
double snapDeckParamNorm(DeckParam id, double norm) {
switch (deckParamUnit(id)) {
case UnitCategory::Milliseconds:
return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm)));
case UnitCategory::Semitones:
// Rate's semitones live in the ratio domain, so its snap round-trips through the rate
// taper rather than the depth one; the other two share the depth throw.
if (id == DeckParam::kRate) {
return rateNormFromRatio(
snapRateRatioToWholeSemitone(
rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio)),
kRateMinRatio, kRateMaxRatio);
}
return depthNormFromSemitones(
snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)),
kPitchDepthMaxSemis);
case UnitCategory::Exponent:
return util::knobNormFromCurve(snapExponentToWhole(util::curveFromKnobNorm(norm)));
case UnitCategory::Decibels:
return engine::masterGainNormFromDb(
std::nearbyint(engine::masterGainDbFromNorm(norm)));
case UnitCategory::Percent:
switch (id) {
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return deckNormFromBipolar(
snapFractionToWholePercent(deckBipolarFromNorm(norm)));
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return keyTrackNormFrom(
snapFractionToWholePercent(keyTrackFromNorm(norm)));
default:
return clamp01(snapFractionToWholePercent(clamp01(norm)));
}
case UnitCategory::None:
break;
}
return norm;
}
} // namespace reasampler::instrument::ui
+57 -21
View File
@@ -1,24 +1,25 @@
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
// what each one's value MEANS.
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its snap units: the
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, and
// the double-click reset. Split from the editor shell so the whole domain map is provable
// without a host; deck_groups owns WHICH controls exist, this owns what each one's value MEANS,
// and param/param_format owns how it READS.
#pragma once
#include <cstddef>
#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range)
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
#include "core/util/clamp01.h"
namespace reasampler::instrument::ui {
using map::PlaySeconds;
// Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the
// parameter set stores. READ from the overlay's schematic scale rather than restated: the AHDSR
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
// parameter set stores. An ALIAS of the overlay's schematic domain, which is itself an alias of
// the taper's; param_taper.h owns why the number has one home.
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch
@@ -28,27 +29,62 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
inline constexpr double kKeyTrackMax = 2.0;
// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is,
// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed
// by per-instance state rather than the parameter set (voice count, master gain, the pitch
// key-track scalar, preview velocity) are not here — the shell reads those from the processor.
// The key-track norm <-> stored pair, shared by BOTH key-track controls. It gets its own home
// because the pitch one's value lives beside the play bundle (on InstrumentParams / SampleData)
// and so cannot ride the PlaySeconds binding below — leaving the editor knob, the host's write
// path, the live fold and the snap to each spell the division out. Every one of them calls these.
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
inline constexpr double kRateMinRatio = engine::kStretchRateMin;
inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
// The normalized [0,1] a control shows: stage times through the shared time taper, levels and
// fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over
// their logarithmic travel. Controls backed by per-instance state rather than the parameter set
// (voice count, master gain, the pitch key-track scalar, preview velocity) are not here — the
// shell reads those from the processor.
double deckParamNorm(DeckParam id, const PlaySeconds& play);
// The STORED value a knob's normalized position maps to — the norm -> value half of the binding
// on its own, because the audio thread needs it without a PlaySeconds to write into
// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two
// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar.
double storedFromNorm(DeckParam id, double norm);
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
// Mutates `play` in place, touching exactly the one field the control names.
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
// second table of defaults to drift from the real one. It arrives via the norm round trip, so
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
// second table of defaults to drift from the real one, and the value is COPIED rather than
// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored
// default bit for bit, and no round trip through a log taper over a non-power-of-two ceiling can
// promise that for every control. Never "simplify" it back into a round trip.
// For knob-valued controls — a toggle has no reset gesture.
void resetDeckParam(DeckParam id, PlaySeconds& play);
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
void formatEnvTimeMs(double seconds, char* buf, std::size_t len);
// The ADDRESS of the one stored field `id` owns — the mechanism resetDeckParam bypasses the taper
// with. Exposed beyond that one caller so a test can verify a reset (or any other mutation)
// against the actual stored field rather than its normalized read-back, which deckParamNorm does
// not guarantee is injective. Null for a control with no reset gesture (a toggle, radio, or
// curve-popup cell) or one whose value lives outside PlaySeconds (master gain, key-track).
double* deckDoubleField(DeckParam id, PlaySeconds& p);
// The filter's four tone controls store their normalized position as float — see deckFloatField's
// definition for why that is a second resolver rather than a widened first one.
float* deckFloatField(DeckParam id, PlaySeconds& p);
// THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's
// processor-side ids (voice count, master gain), which have no entry in the two functions above
// because their VALUE lives outside the parameter set — the unit does not.
UnitCategory deckParamUnit(DeckParam id);
// Applies that snap to a control's normalized value. Snapping happens in the DISPLAYED unit, so
// this is where each control's full scale enters: 0..100 %, 0..200 % and +/-100 % all snap to a
// whole displayed percent and therefore take different norm steps.
double snapDeckParamNorm(DeckParam id, double norm);
} // namespace reasampler::instrument::ui
+99 -40
View File
@@ -11,8 +11,8 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveFromMidLevel;
using util::curveMidLevel;
using util::curveFromLevelAt;
using util::curveLevelAt;
namespace {
@@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
return totalSeconds / static_cast<double>(w);
}
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// dragged handle tracks the cursor 1:1.
double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0;
// The exact inverse of gatePolyline's tapered stage placement: a stage's drawn offset inside its
// slot is slot * timeNormFromSeconds(t), so a pixel delta moves the NORM by dx/slot — never the
// seconds by a fixed rate. Reading the same taper the draw does is what makes a dragged handle
// track the cursor at both ends of the range instead of only near the ceiling.
double gateStageFromPixels(double grabSeconds, const Rect& area, double dxPixels) {
const double slot = gateStageSlotPx(area);
if (slot <= 0.0) return grabSeconds;
return timeSecondsFromNorm(timeNormFromSeconds(grabSeconds) + dxPixels / slot);
}
// Shift's snaps, applied to the resolved param before its clamp so the domain edge always wins.
double snappedSeconds(double seconds, const DragModifiers& m) {
return m.shift ? snapSecondsToWholeMs(seconds) : seconds;
}
double snappedFraction(double fraction, const DragModifiers& m) {
return m.shift ? snapFractionToWholePercent(fraction) : fraction;
}
double snappedExponent(double exponent, const DragModifiers& m) {
return m.shift ? snapExponentToWhole(exponent) : exponent;
}
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
@@ -91,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
return s;
}
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the
// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's.
struct SegmentPixels {
int x0 = 0;
int x1 = 0;
bool ok = false;
};
SegmentPixels segmentPixels(const std::vector<EnvVertex>& poly, EnvNode knot) {
EnvNode startNode, endNode;
switch (knot) {
case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break;
case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break;
case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break;
default: return {};
}
SegmentPixels s;
bool haveStart = false, haveEnd = false;
for (const EnvVertex& v : poly) {
if (v.node == startNode) { s.x0 = v.x; haveStart = true; }
else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; }
}
s.ok = haveStart && haveEnd;
return s;
}
// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see
// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi.
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, int dyPixels) {
double phi, const Rect& area, double dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double span = seg.end - seg.start;
@@ -103,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
// level travel — a segment thinner than that is visually a no-op drag anyway.
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / span);
const double grabLevel = seg.start + span * curveLevelAt(phi, grabExponent);
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
return curveFromLevelAt(phi, (newLevel - seg.start) / span);
}
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
@@ -150,15 +189,29 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
int dxPixels, int dyPixels, const DragModifiers& mods) {
StageEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
const Rect& rect = area.rect;
const double secPerPx = secondsPerPixel(rect, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
// Fine drag scales the PIXEL delta, so it composes with every axis below (the tapered
// schematic, the 1:1 wall clock, the level and the exponent) without a second rule.
const double scale = fineDrag(mods) ? kFineDragScale : 1.0;
const double dx = static_cast<double>(dxPixels) * scale;
const double dy = static_cast<double>(dyPixels) * scale;
const double dSec = dx * secPerPx;
// A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the
// exact phi the knot is sitting at rather than assuming the segment midpoint.
double curvePhi = 0.5;
if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve ||
node == EnvNode::ReleaseCurve) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(grabEnv, area, totalSeconds);
const SegmentPixels sp = segmentPixels(poly, node);
if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1);
}
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
@@ -166,38 +219,43 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.attackSeconds, rect, dx), mods), 0.0,
bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds =
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
out.holdSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.holdSeconds, rect, dx), mods), 0.0,
bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
out.decaySeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.decaySeconds, rect, dx), mods), 0.0,
bounds.maxDecaySeconds);
const double dLevel = -dy * levelPerPixel(rect);
out.sustainLevel =
std::clamp(snappedFraction(grabEnv.sustainLevel + dLevel, mods), 0.0, 1.0);
break;
}
case EnvNode::ReleaseStart:
// The release runs from this node to the anchored right edge, so dragging LEFT
// (negative dx) lengthens it — the delta enters with the opposite sign.
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
out.releaseSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.releaseSeconds, rect, -dx), mods), 0.0,
bounds.maxReleaseSeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
out.releaseCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods);
break;
default:
break;
@@ -209,8 +267,8 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
const AhdSplit s = splitAhdSeconds(grabEnv);
switch (node) {
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds = std::clamp(snappedSeconds(grabEnv.attackSeconds + dSec, mods), 0.0,
bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd: {
// Hold is a fraction of what attack and decay left, so the node's pixel motion
@@ -218,7 +276,7 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// nothing the drag could express.
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
if (rem <= 0.0) break;
out.holdFraction = clamp01((s.hold + dSec) / rem);
out.holdFraction = clamp01(snappedFraction((s.hold + dSec) / rem, mods));
break;
}
case EnvNode::DecayEnd: {
@@ -231,17 +289,18 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// unchanged rather than divided by zero.
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
if (denom > 1e-9) {
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
out.decaySeconds = std::clamp(snappedSeconds(grabEnv.decaySeconds + dSec / denom, mods),
0.0, bounds.maxDecaySeconds);
}
break;
}
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
default:
break;
+6 -3
View File
@@ -51,15 +51,18 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
// `dxPixels`/`dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
// [0, per-param max].
// * X delta -> the node's time param, through the same map the forward draw used — the tapered
// slot on an AHDSR, 1:1 wall clock on an AHD — clamped to [0, per-param max].
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
// exponent. Ignored for time-only nodes.
// * `mods` carries the shared interaction law (param_taper.h): Ctrl scales the pixel delta,
// Shift snaps the resolved param to a whole unit of its own category before the clamp. The
// shell RE-ANCHORS on every modifier transition, so `mods` is constant across one delta.
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
// `grabEnv` unchanged.
// Only the dragged node's param(s) change. Pure.
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
int dxPixels, int dyPixels, const DragModifiers& mods = {});
} // namespace reasampler::instrument::ui
+19 -12
View File
@@ -9,8 +9,7 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveMap;
using util::curveMidLevel;
using util::curveLevelAt;
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
@@ -23,7 +22,7 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
return area.x + static_cast<int>(px + 0.5);
}
double gatePxPerSecond(const Rect& area) {
double gateStageSlotPx(const Rect& area) {
const int w = std::max(0, area.width);
if (w <= 0) return 0.0;
// The four timed stages share the canvas minus their four separation bases and the last
@@ -31,7 +30,7 @@ double gatePxPerSecond(const Rect& area) {
// puts the plateau's end one separation short of the right edge rather than a fixed
// fraction of the way across.
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
return usable / 4.0;
}
int levelToY(const Rect& area, double level) {
@@ -46,6 +45,12 @@ int levelToY(const Rect& area, double level) {
return area.y + static_cast<int>(dy);
}
double knotPhi(int x0, int x1) {
if (x1 == x0) return 0.5;
const int mid = (x0 + x1) / 2;
return static_cast<double>(mid - x0) / static_cast<double>(x1 - x0);
}
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
AhdSplit out;
const double span = std::max(0.0, env.spanSeconds);
@@ -90,14 +95,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool
}
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home).
// pixel midpoint. Its level is read at the phi that midpoint's TRUNCATED x actually implies
// (knotPhi), not always phi = 0.5 — an odd-pixel span would otherwise draw the knot a half
// pixel off the curve its own vertices trace. curve_law.h owns the knot/dial pairing.
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
double endLevel, double exponent) {
const double u = curveMidLevel(exponent);
const double level = startLevel + (endLevel - startLevel) * u;
EnvVertex v;
v.node = node;
v.x = (x0 + x1) / 2;
const double u = curveLevelAt(knotPhi(x0, x1), exponent);
const double level = startLevel + (endLevel - startLevel) * u;
v.y = levelToY(area, level);
v.level = level;
v.knot = true;
@@ -114,17 +121,17 @@ std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area)
const int W = std::max(1, area.width);
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
const double slot = gateStageSlotPx(area);
const double xMax = static_cast<double>(W - 1);
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
// the sustain->release join, and the node the user drags — sits a release-length to its
// left. Everything the release does not take is the sustain plateau, so a zero release
// leaves the plateau running to within one separation of the edge.
double xAttack = sep + a * pps;
double xHold = xAttack + sep + h * pps;
double xDecay = xHold + sep + d * pps;
double xPlateau = xMax - sep - r * pps;
double xAttack = sep + slot * timeNormFromSeconds(a);
double xHold = xAttack + sep + slot * timeNormFromSeconds(h);
double xDecay = xHold + sep + slot * timeNormFromSeconds(d);
double xPlateau = xMax - sep - slot * timeNormFromSeconds(r);
const double xRelease = xMax;
// Keep every node separated when the four stages together would overrun the canvas: the
+19 -7
View File
@@ -9,6 +9,7 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
#include "core/instrument/ui/param_taper.h" // kStageTimeMaxSeconds + the stage-time taper
#include "core/util/curve_law.h" // the ONE per-segment curve law
namespace reasampler::instrument::ui {
@@ -80,14 +81,18 @@ struct EnvVertex {
inline constexpr int kGateNodeSepPx = 8;
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
inline constexpr double kGateStageMaxSeconds = 2.0;
// span at most this. An ALIAS of the taper's own domain end, not a second constant: a maxed knob
// lands exactly at the canvas edge (at which point the sustain plateau has shrunk to nothing)
// only while the two agree.
inline constexpr double kGateStageMaxSeconds = kStageTimeMaxSeconds;
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Width of ONE of the AHDSR schematic's four equal stage slots, independent of the sample's
// actual duration. A stage of `t` seconds fills slot * timeNormFromSeconds(t) pixels of it — the
// axis IS the knob's taper, so a node's position within its slot is that knob's needle position
// drawn a second way. That is what keeps a 3 ms attack legible at a 10 s ceiling (linear in
// seconds put it under a pixel) and what lets envelope_edit's inverse stay the EXACT inverse of
// this draw. Only the AHDSR schematic is tapered; an AHD stays 1:1 wall-clock.
double gateStageSlotPx(const Rect& area);
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
@@ -110,6 +115,13 @@ int timeToX(const Rect& area, double totalSeconds, double t);
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
// The normalized phi a curve knot's TRUNCATED integer x actually lands at within its bounding
// segment [x0, x1] — exactly 0.5 only when the span is even. Shared with envelope_edit's knot
// drag so the draw and its inverse read the same phi off the same formula rather than two
// copies that could drift apart. x0 == x1 (no interior) returns 0.5; callers never place a knot
// there.
double knotPhi(int x0, int x1);
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
+149 -55
View File
@@ -8,10 +8,19 @@ namespace reasampler::instrument::ui {
namespace {
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
// already carries its own breathing room around the 28px knob), plus the optional row
// toggle after a kDeckToggleGap.
// The knob-row width of a group: cells side by side (no inter-cell gap — the 60px cell
// already carries its own breathing room around the 40px knob), plus the optional row
// toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one
// cell wide plus whatever readout column sits beside it.
int knobRowWidth(const DeckGroupDesc& g) {
if (g.row == DeckRow::Spanning) {
int w = g.cellIds.empty() ? 0 : kDeckCellW;
if (g.column.id >= 0) {
if (w > 0) w += kDeckColumnGap;
w += g.column.width;
}
return w;
}
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
if (g.rowToggle.id >= 0) {
if (w > 0) w += kDeckToggleGap;
@@ -29,6 +38,24 @@ int captionRowWidth(const DeckGroupDesc& g) {
return w;
}
// One knob cell inside `cell`: the centered dial square, its concentric inner disc, and the
// label band beneath.
DeckCellLayout layoutCell(int id, const Rect& cell) {
DeckCellLayout c;
c.id = id;
c.cell = cell;
const int knobLeft = cell.x + (cell.width - kDeckKnobSize) / 2;
const int knobTop = cell.y + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(cell.x, labelTop, cell.right(), labelTop + kDeckCellLabelH);
return c;
}
// Place one group's inner geometry given its box.
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
DeckGroupLayout out;
@@ -46,7 +73,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
out.captionRadio = DeckRadioLayout{
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
radioTop + kDeckRadioSize)};
radioTop + kDeckRadioSize),
g.captionRadio.passive};
captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x;
}
@@ -65,10 +93,36 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2);
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
if (g.row == DeckRow::Spanning) {
// FIXED slots down the left column, one per declared id (reserves advance the slot
// without drawing a cell), spaced by a whole row pitch so slot k lands exactly on
// categorical row k's knob baseline. Deliberately NOT the run-division law below.
int slotTop = cellTop;
for (int id : g.cellIds) {
if (id >= 0) {
out.cells.push_back(layoutCell(
id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW,
slotTop + kDeckCellH)));
}
slotTop += kDeckGroupH + kDeckRowGap;
}
if (g.column.id >= 0) {
// ONE rect spanning every slot, not a readout per row. Right-anchored off
// innerRight rather than measured past the cell slot, so a wider caption
// reserve on this group can never detach the column from the padding.
const int colX = innerRight - g.column.width;
out.column = DeckColumnLayout{
g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width,
box.bottom() - kDeckGroupPadY)};
}
return out;
}
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
// id, reserves included). Integer division puts an indivisible residue in symmetric end
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0;
for (int id : g.cellIds) {
@@ -78,19 +132,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
for (int id : g.cellIds) {
if (id < 0) continue;
DeckCellLayout c;
c.id = id;
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
cellTop + kDeckCellH)));
x += cellW;
}
if (g.rowToggle.id >= 0) {
@@ -107,65 +150,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
return out;
}
// The gutters between `count` groups whose widths total `total`, justified space-between
// inside `blockW`. Empty for a single group.
std::vector<int> justifyGutters(int count, int total, int blockW) {
const int gutters = count - 1;
if (gutters <= 0) return {};
const int slack = blockW - total;
if (slack < gutters * kDeckGroupGap) {
// The block cannot hold the row: minimum gutters, and the row overruns to the right
// rather than wrapping — see layoutDeck's header note for when this degrade applies.
return std::vector<int>(static_cast<std::size_t>(gutters), kDeckGroupGap);
}
const int base = slack / gutters;
const int residue = slack % gutters; // both non-negative: slack >= gutters * 12 > 0
std::vector<int> out(static_cast<std::size_t>(gutters), base);
for (int i = 0; i < residue; ++i) ++out[static_cast<std::size_t>(i)];
return out;
}
} // namespace
int deckGroupWidth(const DeckGroupDesc& g) {
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
}
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) {
if (groups.empty()) return 0;
int rows = 1;
int x = 0;
int deckRowCount(const std::vector<DeckGroupDesc>& groups) {
bool sound = false, contour = false;
for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g);
if (x > 0 && x + kDeckGroupGap + w > availWidth) {
++rows;
x = w;
} else {
x += (x > 0 ? kDeckGroupGap : 0) + w;
}
if (g.row == DeckRow::Sound) sound = true;
else if (g.row == DeckRow::Contour) contour = true;
}
return rows;
return (sound ? 1 : 0) + (contour ? 1 : 0);
}
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) {
const int rows = deckRowCount(groups, availWidth);
if (rows == 0) return 0;
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap;
int deckHeight(const std::vector<DeckGroupDesc>& groups) {
const int rows = deckRowCount(groups);
int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0;
for (const DeckGroupDesc& g : groups) {
if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH);
}
return h;
}
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth) {
DeckLayout out;
if (groups.empty()) return out;
int x = left;
int y = top;
bool rowHasGroup = false;
out.rowCount = 1;
for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g);
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) {
// Wrap: whole trailing group onto the next row (mirror of deckRowCount).
++out.rowCount;
x = left;
y += kDeckGroupH + kDeckRowGap;
rowHasGroup = false;
}
if (rowHasGroup) x += kDeckGroupGap;
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
out.groups.push_back(layoutGroup(g, box));
x = box.right();
rowHasGroup = true;
// Partition by the group's OWN row (indices, so the OUTPUT keeps deck order — the shell
// and the tests pair a layout with the descriptor at the same position).
std::vector<std::size_t> rows[2];
std::vector<std::size_t> spanning;
for (std::size_t i = 0; i < groups.size(); ++i) {
const DeckRow row = groups[i].row;
if (row == DeckRow::Spanning) spanning.push_back(i);
else rows[row == DeckRow::Contour ? 1 : 0].push_back(i);
}
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
// The spanning decks take the right edge; the row block is what is left of them.
int spanTotal = 0;
for (std::size_t i : spanning) spanTotal += deckGroupWidth(groups[i]);
if (!spanning.empty()) {
spanTotal += (static_cast<int>(spanning.size()) - 1) * kDeckGroupGap;
}
const int blockW = availWidth - (spanning.empty() ? 0 : spanTotal + kDeckGroupGap);
std::vector<Rect> boxes(groups.size());
int y = top;
for (const std::vector<std::size_t>& row : rows) {
if (row.empty()) continue; // an absent category collapses; it leaves no empty band
++out.rowCount;
int total = 0;
for (std::size_t i : row) total += deckGroupWidth(groups[i]);
const std::vector<int> gutters =
justifyGutters(static_cast<int>(row.size()), total, blockW);
int x = left;
for (std::size_t k = 0; k < row.size(); ++k) {
const int w = deckGroupWidth(groups[row[k]]);
boxes[row[k]] = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
x += w;
if (k < gutters.size()) x += gutters[k];
}
y += kDeckGroupH + kDeckRowGap;
}
int sx = left + availWidth - spanTotal;
for (std::size_t i : spanning) {
const int w = deckGroupWidth(groups[i]);
boxes[i] = Rect::ltrb(sx, top, sx + w, top + kDeckSpanningH);
sx += w + kDeckGroupGap;
}
out.groups.reserve(groups.size());
for (std::size_t i = 0; i < groups.size(); ++i) {
out.groups.push_back(layoutGroup(groups[i], boxes[i]));
}
out.height = deckHeight(groups);
return out;
}
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
if (g.captionRadio.id >= 0 && !g.captionRadio.passive &&
contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
}
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
@@ -185,7 +273,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
}
}
return {}; // inside the box but on fence/padding — a miss (groups never overlap)
if (g.column.id >= 0 && contains(g.column.box, x, y)) {
return {DeckHitKind::Column, g.column.id, -1, false};
}
// Inside the box but on fence/padding — a miss. First-match is exact while the boxes
// are disjoint, which they are at every width the row block fits; under the sub-floor
// overrun an overrunning row can reach the spanning deck and the row group answers.
return {};
}
return {};
}
+84 -30
View File
@@ -1,18 +1,8 @@
// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
// like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// param_slider's — a knob cell here is just a rect the shell composes it into.
//
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// row of equal-width cells (knob centered, label band beneath). A group may also place one
// two-segment toggle in the knob row after its cells. Groups that must keep stable
// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
// neighbouring groups.
//
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// that does not fit the remaining width starts a new row (whole groups only, never
// split); the first group of a row always places even if wider than the row.
// param_slider's — a knob cell here is just a rect the shell composes it into. Group/row
// composition and the justification law are this directory's own CLAUDE.md's to describe.
#pragma once
@@ -23,8 +13,9 @@
namespace reasampler::instrument::ui {
// Fixed deck metrics, exposed so the shell and tests agree. The cell/knob/label sizes were
// raised together for legibility at high pixel densities; the editor's floor width
// (sample_bands) is what absorbs the wider cells, so the two move as a pair.
// raised together for legibility at high pixel densities. The deck's cell metrics AND its
// group/row composition BOTH drive sample_bands' kEditorMinWidth; none of the three may move
// alone.
inline constexpr int kDeckCellW = 60; // one knob cell
inline constexpr int kDeckCellH = 74;
inline constexpr int kDeckKnobSize = 40; // knob diameter inside the cell
@@ -36,8 +27,10 @@ inline constexpr int kDeckGroupPadY = 4; // group box vertical inner paddin
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
inline constexpr int kDeckRowGap = 8; // gap between the deck's two categorical rows,
// and between the spanning deck's stacked slots
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
@@ -45,6 +38,32 @@ inline constexpr int kDeckInnerDialSize = 20;
// One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
// The spanning deck's box: it stands across both categorical rows AND the seam between them,
// which is what lets its two cell slots land on the two rows' own knob baselines.
inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap;
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared
// here rather than with the group inventory because the LAYOUT is what reads it; which group
// sits in which row is deck_groups' deckRowFor.
enum class DeckRow { Sound, Contour, Spanning };
// --- The deck's width budget at the editor's floor ------------------------------------
// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a
// group inventory that overruns one is what fails. sample_bands' kEditorMinWidth is derived
// from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the
// identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no
// include edge to this header.
// 1028 is the width at which the justification law puts BOTH rows' filter groups on the same
// right edge (x = 640 block-relative) AND divides row 1's slack into three equal gutters. The
// narrower 1020 delivered neither: 40 px over three gutters is 13⅓, so the law produced
// 14/13/13 and left row 1's filter edge 2 px past row 2's.
inline constexpr int kDeckRowBlockW = 1028; // the block both categorical rows justify inside
inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block
// The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 82px,
// the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see
// instrument-control-surface.md §1.6 before spending any of it.
// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent.
struct DeckToggleDesc {
@@ -53,15 +72,29 @@ struct DeckToggleDesc {
};
// A single-square corner radio (an exclusive selector across groups, so the group itself
// carries no state). id -1 = absent.
// carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the
// hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it.
struct DeckRadioDesc {
int id = -1;
bool passive = false;
};
// A full-height readout column beside a spanning group's cell slots (the output meter). Only
// a Spanning group may carry one — a categorical row's groups have no height to span.
// id -1 = absent.
struct DeckColumnDesc {
int id = -1;
int width = 0;
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text).
//
// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per
// declared id, at successive row baselines, reserves included. The run-division law above is
// horizontal only — applied vertically it would stretch a lone knob over the whole box.
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
@@ -73,6 +106,8 @@ struct DeckGroupDesc {
DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
DeckRow row = DeckRow::Sound;
DeckColumnDesc column; // Spanning groups only; id -1 = none
};
// --- Laid-out geometry ---------------------------------------------------------------
@@ -86,6 +121,12 @@ struct DeckToggleLayout {
struct DeckRadioLayout {
int id = -1;
Rect box;
bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc
};
struct DeckColumnLayout {
int id = -1;
Rect box;
};
struct DeckCellLayout {
@@ -105,34 +146,46 @@ struct DeckGroupLayout {
DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
DeckColumnLayout column; // id -1 when absent (Spanning groups only)
};
struct DeckLayout {
std::vector<DeckGroupLayout> groups;
int rowCount = 0;
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither.
int height = 0; // the tallest thing laid out; 0 for no groups
};
// Width of one group box: the wider of its caption row (caption + gap + toggle) and its
// knob row (cells + gap + row toggle), plus horizontal padding.
// Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and
// its knob row, plus horizontal padding. A Spanning group's knob row is one cell wide plus
// its readout column, because its cells stack.
int deckGroupWidth(const DeckGroupDesc& g);
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
// 0 for an empty list.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// How many of the two categorical rows carry at least one group (0..2). Independent of width:
// row membership is the group's own property.
int deckRowCount(const std::vector<DeckGroupDesc>& groups);
// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
// Total deck height: the categorical rows, or the spanning deck when it is taller. The shell
// bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
int deckHeight(const std::vector<DeckGroupDesc>& groups);
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
// rule. Every rect is absolute.
// Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and
// `groups` comes back in DECK order — the same position as the descriptor it was built from,
// whichever row that descriptor landed in.
//
// Spanning groups are right-anchored at `left + availWidth` and take no part in either row's
// justification; the ROW BLOCK is what remains to their left. Inside the block each row is
// justified SPACE-BETWEEN: groups keep their natural widths and the slack becomes gutters,
// divided equally with the integer residue going to the leftmost ones. Decks are never
// stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row
// overflows right rather than wrapping — the shell clamps the window to a floor that fits
// (sample_bands' kEditorMinWidth) via checkSizeConstraint, a host-honoured clamp rather than a
// guarantee, so this degrade is defined and tested rather than assumed impossible.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth);
// --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column };
struct DeckHit {
DeckHitKind kind = DeckHitKind::None;
@@ -143,8 +196,9 @@ struct DeckHit {
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
// the caption-row corner radio. Everything else — fence, padding, outside — misses.
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an
// interactive caption-row corner radio, or a spanning group's readout column. Everything
// else — fence, padding, a PASSIVE radio, outside — misses.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
// The knob FACE a point lands on. id -1 is a miss.
+63
View File
@@ -0,0 +1,63 @@
// loop_marks.cpp — see loop_marks.h. Pure value folds; no host types.
#include "core/instrument/ui/loop_marks.h"
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds
namespace reasampler::instrument::ui {
namespace {
// The engine's own acceptance test, restated over the editor's two integers: resolveLoop
// refuses an inverted, empty or out-of-range span rather than repairing it, so a span it would
// refuse is one the handles must be re-parked out of.
bool spanUsable(std::int64_t loopStart, std::int64_t loopEnd, std::int64_t frameCount) {
return loopStart >= 0 && loopEnd > loopStart && loopEnd <= frameCount;
}
} // namespace
LoopMarks resolveLoopMarks(const StoredLoop& stored, std::int64_t frameCount) {
LoopMarks m;
if (stored.override_) {
m.hasLoop = stored.override_->hasLoop;
m.loopStart = stored.override_->start;
m.loopEnd = stored.override_->end;
} else if (stored.intrinsic && stored.intrinsic->hasLoop) {
m.hasLoop = true;
m.loopStart = stored.intrinsic->start;
m.loopEnd = stored.intrinsic->end;
}
if (stored.startPoint) m.start = *stored.startPoint;
m.crossfade = stored.crossfade > 0 ? stored.crossfade : 0;
if (!spanUsable(m.loopStart, m.loopEnd, frameCount)) {
m.hasLoop = false;
m.parked = true;
const engine::loop::LoopBounds d = engine::loop::defaultLoopBounds(frameCount);
m.loopStart = d.start;
m.loopEnd = d.end;
}
return m;
}
LoopWrite applyLoopMarks(const LoopMarks& m) {
LoopWrite w;
const bool spanAlive = m.loopEnd > m.loopStart;
w.loop.hasLoop = m.hasLoop && spanAlive;
if (m.parked && !m.hasLoop) {
// A parked pair (never set) must round-trip back to parked, not to a real "LOOP OFF"
// span — resolveLoopMarks only re-parks a span spanUsable would refuse, so collapse it
// deliberately rather than writing back the default bounds' own alive span.
w.loop.start = m.loopStart;
w.loop.end = m.loopStart;
} else {
w.loop.start = m.loopStart;
w.loop.end = m.loopEnd;
}
w.crossfade = (spanAlive && m.crossfade > 0) ? m.crossfade : 0;
w.start = m.start;
return w;
}
} // namespace reasampler::instrument::ui
+60
View File
@@ -0,0 +1,60 @@
#pragma once
// loop_marks.h — the loop enable's state machine: what the waveform band SHOWS for a stored
// loop, and what a marker edit WRITES back. `SampleLoop::hasLoop` is the single authority;
// collapse-to-off and drag-to-create are shortcuts onto it, not a second state. Pure values
// only — the shell supplies the stored side and applies the result.
#include <cstdint>
#include <optional>
#include "core/instrument/engine/play_params.h" // SampleLoop
namespace reasampler::instrument::ui {
using ::reasampler::SampleLoop;
// What the four marks are showing. `parked` separates the two OFF states: a pair sitting on
// defaultLoopBounds because nothing was ever set (there is a loop to "set") from a real span
// the user switched off (there is not).
struct LoopMarks {
std::int64_t start = 0;
std::int64_t loopStart = 0;
std::int64_t loopEnd = 0;
std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames
bool hasLoop = false;
bool parked = false;
};
// The stored side: the parameter set's loop override, the bank's loop intrinsic (consulted only
// when there is no override — the override always supersedes it), the crossfade length, and the
// start point.
struct StoredLoop {
std::optional<SampleLoop> override_;
std::optional<SampleLoop> intrinsic;
std::int64_t crossfade = 0;
std::optional<std::int64_t> startPoint;
};
// Reads the stored loop into what the band shows. A span the engine could not honour —
// collapsed, inverted, or outside [0, frameCount] — re-parks on defaultLoopBounds so two
// coincident handles can never become ungrabbable; a VALID span keeps its own positions
// whatever the enable says, which is what makes the enable a toggle rather than a delete
// button.
LoopMarks resolveLoopMarks(const StoredLoop& stored, std::int64_t frameCount);
// The write side, the inverse of resolveLoopMarks.
struct LoopWrite {
SampleLoop loop;
std::int64_t crossfade = 0;
std::int64_t start = 0;
};
// Folds collapse-to-off in: a span dragged onto itself is the OFF gesture, recorded as such so
// the next resolve re-offers the default handles. The crossfade goes with the SPAN, not with
// the enable — zeroed only when the span is destroyed. That preserves the original zeroing
// rule's reason rather than overruling it: a stale length could silently re-apply against a
// span that no longer exists, but a retained span retains its clamp bound too, so nothing is
// stale.
LoopWrite applyLoopMarks(const LoopMarks& m);
} // namespace reasampler::instrument::ui
+113
View File
@@ -0,0 +1,113 @@
// master_meter.cpp — see master_meter.h.
#include "core/instrument/ui/master_meter.h"
namespace reasampler::instrument::ui {
bool meterTickNumeralled(int db) {
// Every OTHER 6 dB tick, which is the 0/12/24/36/48/60 set the scale is specified as.
return db % (2 * static_cast<int>(kMeterTickStepDb)) == 0;
}
MeterRects meterRects(const Rect& column, LaneSplit split) {
MeterRects r;
if (column.width < kMeterColumnW || column.height <= 0) return r;
r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom());
const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap;
r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom());
if (split == LaneSplit::Single) {
r.barA = r.field;
return r;
}
const int barW = (kMeterFieldW - kMeterBarGap) / 2;
r.barA = Rect::ltrb(fieldLeft, column.y, fieldLeft + barW, column.bottom());
const int bLeft = r.barA.right() + kMeterBarGap;
r.barB = Rect::ltrb(bLeft, column.y, bLeft + barW, column.bottom());
return r;
}
Rect meterNumeralRect(const Rect& labels, int y) {
if (labels.empty()) return {};
int top = y - 5;
if (top < labels.y) top = labels.y;
int bottom = top + 10;
if (bottom > labels.bottom()) {
bottom = labels.bottom();
top = bottom - 10 < labels.y ? labels.y : bottom - 10;
}
return Rect::ltrb(labels.x, top, labels.right(), bottom);
}
int meterDbToY(const Rect& field, double db) {
const double norm = engine::meterNormFromDb(db);
const int y = field.bottom() - static_cast<int>(norm * field.height + 0.5);
if (y < field.y) return field.y;
if (y > field.bottom()) return field.bottom();
return y;
}
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
double elapsedSeconds) {
MasterMeterUi next;
next.left = engine::advanceMeter(prev.left, block.peakL, elapsedSeconds);
next.right = engine::advanceMeter(prev.right, block.peakR, elapsedSeconds);
// ORed in unconditionally. Now that the peaks accumulate, advanceMeter's own >= 0 dBFS
// check sees the same window and would latch too — but the published flag stays the
// definitive one, and it is the half clearMasterBusClip resets.
if (block.clip) {
next.left.clip = true;
next.right.clip = true;
}
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double reduction = -engine::meterDbFromLinear(block.minGain);
// Same hold-then-release shape as the peak tick, for the same reason: at the UI period the
// lamp actually runs at, a bare decay retires a catch before it has been drawn twice.
if (reduction >= prev.reductionDb) {
next.reductionDb = reduction;
next.reductionHoldSeconds = engine::kMeterPeakHoldSeconds;
} else {
next.reductionDb = prev.reductionDb;
next.reductionHoldSeconds = prev.reductionHoldSeconds - dt;
if (next.reductionHoldSeconds < 0.0) {
// Spend the overshoot as fall time so the release does not quantize to whichever
// UI frame the hold happened to expire on.
const double fallen =
next.reductionDb - engine::kMeterFallDbPerSecond * -next.reductionHoldSeconds;
next.reductionDb = fallen > reduction ? fallen : reduction;
next.reductionHoldSeconds = 0.0;
}
}
if (next.reductionDb < 0.0) next.reductionDb = 0.0;
return next;
}
bool meterClipped(const MasterMeterUi& m) { return m.left.clip || m.right.clip; }
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) {
MasterMeterUi next = prev;
next.left = engine::clearMeterClip(prev.left);
next.right = engine::clearMeterClip(prev.right);
return next;
}
engine::MeterState meterSingleLaneState(const MasterMeterUi& m) {
engine::MeterState s;
s.levelDb = m.left.levelDb > m.right.levelDb ? m.left.levelDb : m.right.levelDb;
s.holdDb = m.left.holdDb > m.right.holdDb ? m.left.holdDb : m.right.holdDb;
s.holdRemainingSeconds = m.left.holdRemainingSeconds > m.right.holdRemainingSeconds
? m.left.holdRemainingSeconds
: m.right.holdRemainingSeconds;
s.clip = m.left.clip || m.right.clip;
return s;
}
bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; }
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) {
return a.left.levelDb == b.left.levelDb && a.right.levelDb == b.right.levelDb &&
a.left.holdDb == b.left.holdDb && a.right.holdDb == b.right.holdDb &&
meterClipped(a) == meterClipped(b) && grLampLit(a) == grLampLit(b);
}
} // namespace reasampler::instrument::ui
+101
View File
@@ -0,0 +1,101 @@
// master_meter.h — the interior of the spanning deck's readout column: the label gutter and
// bar field it divides into, the dB->y map its scale draws against, and the per-instance UI
// state the bars are drawn from. knob_deck hands over the column rect; this lays out inside
// it. Every timed, logged or latched quantity lives here, on the UI thread — the audio thread
// publishes raw block magnitudes and converts nothing.
#pragma once
#include "core/instrument/engine/meter_ballistics.h"
#include "core/instrument/ui/editor_geometry.h"
#include "core/instrument/ui/sample_bands.h" // LaneSplit
namespace reasampler::instrument::ui {
inline constexpr int kMeterLabelW = 22; // the numeral gutter, left of the bars
inline constexpr int kMeterLabelGap = 4;
inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart
inline constexpr int kMeterBarGap = 2;
// What the interior consumes, and therefore the width the deck must RESERVE for the column.
// knob_deck's MASTER descriptor reads this rather than restating 62 — §1.2 banks MASTER's
// growth to 236 as the meter's growth room, so this constant is expected to move.
inline constexpr int kMeterColumnW = kMeterLabelW + kMeterLabelGap + kMeterFieldW;
// A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier.
inline constexpr double kMeterTickStepDb = 6.0;
// Whether the tick at `db` carries a numeral. The numeral SET is spec-pinned (0, 12, 24,
// 36, 48, 60), so it lives beside the step it is derived from rather than in the painter.
bool meterTickNumeralled(int db);
struct MeterRects {
Rect labels; // the numeral gutter
Rect field; // the whole bar field
Rect barA; // Single: the one wide bar. Stereo: L.
Rect barB; // empty() unless Stereo
};
// `split` is the RESOLVED lane decision resolveLaneSplit already folds (channel mode AND the
// source's channel count), not "is the instrument in stereo mode": a mono source under stereo
// mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule.
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
MeterRects meterRects(const Rect& column, LaneSplit split);
// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom,
// linear in dB between, clamped outside.
int meterDbToY(const Rect& field, double db);
// The numeral's label rect for the tick at `y`, kept inside the gutter: the floor tick sits ON
// the field's bottom edge, and an unclamped y±5 box would hang below the column.
Rect meterNumeralRect(const Rect& labels, int y);
// The per-instance UI state behind the column. One clip latch per channel (the cap is drawn
// once, over whichever of them tripped).
struct MasterMeterUi {
engine::MeterState left;
engine::MeterState right;
double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working
// The lamp's hold, on the SAME principle (and the same window) as the peak tick's: without
// it a catch smaller than kMeterFallDbPerSecond x the UI period is fully decayed by the
// next frame, so the lamp is dark again after the single repaint the catch landed on.
double reductionHoldSeconds = 0.0;
};
// What the audio thread published SINCE THE LAST READ, in this module's own vocabulary — the
// peaks are a max over every block in that window and minGain a min, so no block is discarded
// unseen between two UI frames.
struct MasterMeterBlock {
double peakL = 0.0;
double peakR = 0.0;
double minGain = 1.0; // the limiter's smallest gain over the window; 1 = no reduction
bool clip = false; // the AUDIO thread's own latch
};
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
double elapsedSeconds);
bool meterClipped(const MasterMeterUi& m);
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev);
// What the ONE bar shows on a single-lane column: the two channels folded per FIELD, not the
// louder channel's whole state. Picking a channel by level would draw a hold tick and a clip
// belonging to whichever won on level — inert while L ≡ R on every path that reaches Single,
// and wrong the moment they diverge.
engine::MeterState meterSingleLaneState(const MasterMeterUi& m);
// Whether two states would DRAW the same, so the UI tick can repaint only on a change and an
// idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap
// and the lamp — not every stored double.
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the
// spec asks only for "a small floor". It is bounded on BOTH sides: raising it hides genuine
// catches, since the limiter's ceiling is only 0.3 dBTP; lowering it turns the lamp into a
// "some sample crossed the ceiling" light, because the gain law is ceiling/peak and so reports
// an arbitrarily small reduction for a peak arbitrarily close to the ceiling.
inline constexpr double kGrLampFloorDb = 0.5;
bool grLampLit(const MasterMeterUi& m);
} // namespace reasampler::instrument::ui
+4 -2
View File
@@ -135,11 +135,13 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v
knob.centerY - knob.radius * std::cos(rad)};
}
double knobDragValue(double startValue, int dyPixels, int dragRangePixels) {
double knobDragValue(double startValue, int dyPixels, const DragModifiers& mods,
int dragRangePixels) {
const double start = clamp01(startValue);
if (dragRangePixels <= 0) return start;
const double dy = static_cast<double>(dyPixels) * (fineDrag(mods) ? kFineDragScale : 1.0);
// Screen y grows downward: an upward drag (negative dy) increases the value.
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
return clamp01(start - dy / dragRangePixels);
}
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
+10 -2
View File
@@ -15,6 +15,7 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law)
namespace reasampler::instrument::ui {
@@ -129,8 +130,15 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v
// Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
// `dyPixels` the pointer's y displacement (down = positive). Up increases, down
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
double knobDragValue(double startValue, int dyPixels,
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range. Ctrl in
// `mods` scales the rate (param_taper.h); Shift's snap is NOT applied here — the whole
// unit it snaps to is a property of the control's unit category, which this module does
// not know, so the caller applies it to the returned norm.
//
// The drag is grab-anchored ABSOLUTE, which is why the caller must re-anchor `startValue`
// and its own grab y on every modifier transition: rescaling an accumulated delta in place
// would jump the value by (1 - kFineDragScale) x whatever had accumulated.
double knobDragValue(double startValue, int dyPixels, const DragModifiers& mods = {},
int dragRangePixels = kKnobDragRangePixels);
// Control a point lands on, given laid-out `rows`. Returns the control id whose
+129
View File
@@ -0,0 +1,129 @@
// param_taper.cpp — see param_taper.h. Pure value math; no host types.
#include "core/instrument/ui/param_taper.h"
#include <cmath>
namespace reasampler::instrument::ui {
namespace {
// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: std::round(v*S)/S is the
// correctly-rounded double of k/S, which is the same double a decimal literal of k/S parses to —
// so a default written as 0.003 or 0.060 lands on the grid exactly. A power-of-two quantum would
// not have that property against decimal literals.
constexpr double kSecondsPerQuantum = 1e9; // 1 ns
constexpr double kSemitonesPerQuantum = 1e6; // 1 micro-semitone
// std::nearbyint reads the CURRENT FP rounding mode (MXCSR) — not exclusively ours on a DAW's UI
// thread. Under round-toward-zero it can drop a grid value by a whole quantum, which is exactly
// what the quantization scheme exists to prevent. std::round (half-away-from-zero) is the same
// regardless of that mode, which is what makes the EXACT PREIMAGE guarantee (header) structural.
double resolveTo(double value, double perUnit) { return std::round(value * perUnit) / perUnit; }
// The shifted-log offsets. Both are FITTED AGAINST THE CEILING above them, which is why the
// ceiling could not be raised in a later track: doing the two apart means fitting twice.
constexpr double kTimeOffsetSeconds = 0.003; // -> 10 ms at 0.181, 100 ms at 0.436
constexpr double kDepthOffsetSemitones = 3.0; // -> +/-7 st at 0.548 of each half-travel
double timeSpan() { return std::log1p(kStageTimeMaxSeconds / kTimeOffsetSeconds); }
double depthSpan(double maxSemitones) {
return std::log1p(maxSemitones / kDepthOffsetSemitones);
}
double rateSpanOctaves(double minRatio, double maxRatio) {
return std::log2(maxRatio / minRatio);
}
// The norm the general formula puts unity at, DERIVED from the bounds rather than assumed to be
// centre — it is 0.5 only when minRatio * maxRatio == 1. Both maps below pin their exact-unity
// case to this one expression, so the detent is where the curve already goes and the round trip
// closes bitwise on it. Spelling it 0.5 was correct for the shipped symmetric bounds and would
// have gone non-monotone the moment they were re-measured asymmetric.
double rateUnityNorm(double minRatio, double maxRatio) {
return -std::log2(minRatio) / rateSpanOctaves(minRatio, maxRatio);
}
} // namespace
double timeNormFromSeconds(double seconds) {
if (!(seconds > 0.0)) return 0.0; // also catches NaN
if (seconds >= kStageTimeMaxSeconds) return 1.0;
return std::log1p(seconds / kTimeOffsetSeconds) / timeSpan();
}
double timeSecondsFromNorm(double norm) {
if (!(norm > 0.0)) return 0.0;
if (norm >= 1.0) return kStageTimeMaxSeconds;
return resolveTo(kTimeOffsetSeconds * std::expm1(norm * timeSpan()), kSecondsPerQuantum);
}
double depthNormFromSemitones(double semitones, double maxSemitones) {
if (!(maxSemitones > 0.0)) return 0.5;
if (semitones == 0.0) return 0.5; // the centre is EXACT, so a knob parked there persists
const double mag = std::fabs(semitones); // no depth at all
if (!(mag < maxSemitones)) return semitones > 0.0 ? 1.0 : 0.0;
const double u = std::log1p(mag / kDepthOffsetSemitones) / depthSpan(maxSemitones);
return semitones > 0.0 ? 0.5 + 0.5 * u : 0.5 - 0.5 * u;
}
double depthSemitonesFromNorm(double norm, double maxSemitones) {
if (!(maxSemitones > 0.0)) return 0.0;
if (!(norm > 0.0)) return -maxSemitones; // also catches NaN
if (norm >= 1.0) return maxSemitones;
if (norm == 0.5) return 0.0;
const double u = std::fabs(norm - 0.5) * 2.0;
const double mag = resolveTo(kDepthOffsetSemitones * std::expm1(u * depthSpan(maxSemitones)),
kSemitonesPerQuantum);
return norm > 0.5 ? mag : -mag;
}
double rateNormFromRatio(double ratio, double minRatio, double maxRatio) {
if (!(maxRatio > minRatio && minRatio > 0.0)) return 0.5; // degenerate bounds: park at unity
if (!(ratio > minRatio)) return 0.0; // also catches NaN
if (ratio >= maxRatio) return 1.0;
// The unity detent is EXACT, so unity persists as unity.
if (ratio == 1.0) return rateUnityNorm(minRatio, maxRatio);
return std::log2(ratio / minRatio) / rateSpanOctaves(minRatio, maxRatio);
}
double rateRatioFromNorm(double norm, double minRatio, double maxRatio) {
if (!(maxRatio > minRatio && minRatio > 0.0)) return 1.0;
if (!(norm > 0.0)) return minRatio; // also catches NaN
if (norm >= 1.0) return maxRatio;
if (norm == rateUnityNorm(minRatio, maxRatio)) return 1.0;
// NOT resolved onto a decimal quantum, unlike the two maps above, and the difference is
// principled rather than an omission: this control's only default is unity, which the exact
// detent case above already delivers bitwise, so a grid would buy no preimage it does not
// already have — while costing accuracy at every whole semitone, none of which is a decimal
// ratio. Left as the plain exponential, accurate to an ulp.
return minRatio * std::exp2(norm * rateSpanOctaves(minRatio, maxRatio));
}
double snapSecondsToWholeMs(double seconds) {
if (!(seconds > 0.0)) return 0.0;
return std::round(seconds * 1000.0) / 1000.0;
}
double snapFractionToWholePercent(double fraction) {
return std::round(fraction * 100.0) / 100.0;
}
double snapSemitonesToWhole(double semitones) { return std::round(semitones); }
// Rounding lands on 1..10; anything under half a unit clamps to the domain floor rather than to
// zero, which is not an exponent. 1.0, the linear neutral, is therefore one snap from centre.
double snapExponentToWhole(double exponent) {
return util::clampCurve(std::round(util::clampCurve(exponent)));
}
double snapRateRatioToWholeSemitone(double ratio) {
if (!(ratio > 0.0)) return 1.0; // also catches NaN: an unusable rate snaps to unity
// exp2 of a whole number of twelfths: 0 gives exactly 1.0 and +/-12 exactly halving/doubling,
// so a snap to the detent or to either end lands on the taper's own endpoint doubles. An
// in-range input stays in range, which is why this takes no bounds.
return std::exp2(std::round(12.0 * std::log2(ratio)) / 12.0);
}
} // namespace reasampler::instrument::ui
+108
View File
@@ -0,0 +1,108 @@
// param_taper.h — THE norm <-> value tapers every variable control shares, plus the modifier
// vocabulary its drag surfaces read. Extracted from deck_values because three consumers in two
// dependency layers read it — the knob's needle, the AHDSR overlay's schematic axis and its drag
// inverse, and the VST3 host's normalization — and three functions that agree today is a defect.
#pragma once
#include "core/util/curve_law.h" // the exponent domain the whole-number snap clamps into
namespace reasampler::instrument::ui {
// --- the interaction law's modifiers -------------------------------------------------------
// Ctrl divides the drag rate by 20. Shift+Ctrl is SHIFT, Ctrl ignored: with the output quantized
// to whole units a finer drag yields the same sequence of values, so that is an identity rather
// than a compromise — do not "fix" it into a compounded scale.
inline constexpr double kFineDragScale = 0.05;
struct DragModifiers {
bool shift = false; // snap to whole units of the control's displayed unit
bool ctrl = false; // fine drag
bool operator==(const DragModifiers& o) const { return shift == o.shift && ctrl == o.ctrl; }
bool operator!=(const DragModifiers& o) const { return !(*this == o); }
};
inline bool fineDrag(const DragModifiers& m) { return m.ctrl && !m.shift; }
// Which whole unit Shift snaps a control to. Derived from the control's UNIT rather than from a
// per-widget list, so a control added later inherits the law by naming its category.
enum class UnitCategory {
None, // discrete, already-integer, or non-scalar controls — Shift changes nothing
Milliseconds,
Semitones,
Percent,
Exponent,
Decibels,
};
// --- the two tapers ------------------------------------------------------------------------
//
// EXACT PREIMAGE, and why it is structural rather than lucky. A host's reset-to-default arrives
// as toPlain(defaultNorm) with no editor-side bypass available, so every default must satisfy
// toPlain(toNormalized(d)) == d BITWISE. No transcendental map delivers that at an arbitrary
// interior point — the image of toPlain is sparser there than the doubles around it — so both
// maps below resolve their output onto a fixed decimal quantum, via std::round rather than
// std::nearbyint: round is half-away-from-zero regardless of the caller's FP rounding mode, so
// the quantization is mode-independent, not just decimal-exact. That turns the guarantee into
// "every value on the quantum grid round-trips exactly" instead of a libm/MXCSR coincidence that
// a compiler upgrade or a host's UI thread could take away. Both quanta sit roughly 3.7-4 orders
// below the finest reachable drag step (time ~3.98, depth ~3.71), so nothing observable is
// quantized. The converse, toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must
// not be demanded: no log map satisfies it in double, and requiring it would rule out the shape
// the range needs.
// The stage-time domain's upper end — the value at norm 1, and the one home of that number:
// envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds are both aliases
// of it, so the schematic's canvas edge and the knob's ceiling cannot drift apart. Once the
// instrument reports VST3 parameters this endpoint is a frozen host normalization — moving it
// re-interprets every automation point already recorded in projects we do not own.
inline constexpr double kStageTimeMaxSeconds = 10.0;
// Shifted-log: exactly 0 s at norm 0, exactly kStageTimeMaxSeconds at norm 1, monotone
// throughout, low end expanded so 10 ms sits at ~0.18 of the travel and 100 ms at ~0.44. A pure
// log cannot include zero and zero is a required value, which is what the offset buys.
double timeNormFromSeconds(double seconds);
double timeSecondsFromNorm(double norm);
// Signed depth, symmetric about norm 0.5: exactly 0 semitones at centre, exactly +/-maxSemitones
// at the ends, monotone, with the musically useful middle expanded so +/-7 st reaches ~0.55 of
// each half-travel. The throw is a PARAMETER — the +/-24 st depth constant has its own home in
// the engine's value layer and is not restated here.
double depthNormFromSemitones(double semitones, double maxSemitones);
double depthSemitonesFromNorm(double norm, double maxSemitones);
// Playback RATE as a ratio, exponential across the travel — i.e. LINEAR IN SEMITONES, the one
// exception to the centre expansion above. Centre expansion applies to a semitone knob whose
// throw exceeds +/-12; this throw IS +/-12 (half rate to double rate), already 0.19 st per drag
// pixel, so expanding it would buy resolution nothing needs and cost the extremes.
//
// The bounds are PARAMETERS for the same reason the depth throw is: they belong to the engine's
// stretcher, which owns the measurement they came from, and a second copy here could drift from
// it. The map is monotone and hits them exactly at norm 0 and 1, so a norm in [0,1] cannot reach
// a ratio the engine's own clamp would then move — ONE clamp, at the stretcher, not two.
// Exactly 1.0 at the norm the bounds themselves put unity at — `-log2(minRatio) / span`, which
// is 0.5 only when minRatio * maxRatio == 1 — whenever they bracket it. That detent is this
// control's whole preimage obligation; see rateRatioFromNorm for why it carries no output
// quantum. Pinning it to 0.5 regardless of the bounds is the specific mistake to avoid: it makes
// the map non-monotone the moment the stretcher's measured range stops being symmetric.
double rateNormFromRatio(double ratio, double minRatio, double maxRatio);
double rateRatioFromNorm(double norm, double minRatio, double maxRatio);
// --- Shift's whole-unit snaps, in the VALUE domain -------------------------------------------
//
// Stated over values rather than norms because "whole unit" means whole unit of what the control
// DISPLAYS: two controls sharing a category can have different full scales, so the norm step is
// the caller's business and the unit is this module's.
double snapSecondsToWholeMs(double seconds);
double snapFractionToWholePercent(double fraction); // 1.0 == 100 %
double snapSemitonesToWhole(double semitones);
double snapExponentToWhole(double exponent); // clamped into curve_law's own domain
// Rate's unit is the SEMITONE even though it displays as a percent, so its whole unit is one of
// the 25 semitone steps between the bounds — which is what puts an octave and a fifth under the
// hand. Stated over the ratio because that is what the control stores.
double snapRateRatioToWholeSemitone(double ratio);
} // namespace reasampler::instrument::ui
+14 -2
View File
@@ -16,9 +16,21 @@ inline constexpr int kPad = 8;
// exactly this, and there is no scroll, so anything smaller pushes the deck band off the
// window bottom (computeSampleBands' waveform-floor-wins degrade). Growing is fine — the
// waveform band is the elastic one. Both the enforced minimum and the opening rect read this.
inline constexpr int kEditorMinWidth = 980;
//
// The width is a LITERAL here on purpose, though it is derived from knob_deck's width budget:
// this allocator is deliberately independent of the deck (it takes deckHeight as a parameter
// for exactly that reason), so the derivation is asserted in test_deck_groups.cpp — the one
// place that already includes both headers — rather than coded as an include edge.
inline constexpr int kEditorMinWidth = 1198;
inline constexpr int kEditorMinHeight = 680;
// The hard ceiling the floor above may not exceed; the window itself still grows freely above
// it. A window fact, sibling of kEditorMinWidth/kEditorMinHeight, not a deck one — moved here
// from knob_deck.h for that reason. The gap to the floor (today: 82px) is the deck's whole
// width budget, spent once; the identity is asserted in test_deck_groups.cpp, the one place
// that already includes both this header and knob_deck.h.
inline constexpr int kEditorCeilingWidth = 1280;
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
// preview, velocity knob, channel toggle). sample_chrome partitions it.
inline constexpr int kTitleHeight = 26;
@@ -43,7 +55,7 @@ struct SampleBands {
};
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
// height (from knob_deck) — the only interior measurement the allocator needs, so
// the deck band is exactly as tall as its content. Pure.
SampleBands computeSampleBands(int w, int h, int deckHeight);
+13 -2
View File
@@ -19,6 +19,10 @@ constexpr int kStripBandHeight = 30;
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18;
// The loop enable's segments carry a two-word label, so they are wider than Mono|Stereo's.
// If the title slot ever fails to hold its text at the editor's floor, THIS narrows — the
// floor does not move.
constexpr int kLoopSegW = 58;
constexpr int kVelCellW = 56;
constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell
constexpr int kVelLabelH = 16;
@@ -43,7 +47,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, hold.
// The fixed run, right to left: Browse, Mono|Stereo, Loop Off|On, 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);
@@ -58,9 +63,15 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
chanTop + kChanSegH);
const int loopRight = leftOf(r.chanMono.x, kRunGap);
r.loopOn = Rect::ltrb(leftOf(loopRight, kLoopSegW), chanTop, loopRight,
chanTop + kChanSegH);
r.loopOff = Rect::ltrb(leftOf(r.loopOn.x, kLoopSegW), chanTop, r.loopOn.x,
chanTop + kChanSegH);
const int cellH = std::min(row.height, knobSize + kVelLabelH);
const int cellTop = topFor(cellH);
const int cellRight = leftOf(r.chanMono.x, kRunGap);
const int cellRight = leftOf(r.loopOff.x, kRunGap);
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
+7 -1
View File
@@ -2,7 +2,8 @@
// sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row
// (title + the whole right-anchored control run + Browse) over the strip row, which the
// piano strip has to itself. Reads the band rect the allocator hands it (sample_bands) and
// never allocates vertical space of its own.
// never allocates vertical space of its own. The run is right-anchored and the title takes
// the remainder, so a member added to the run costs the title, never the window's floor.
#include "core/instrument/ui/editor_geometry.h" // Rect
@@ -32,6 +33,11 @@ struct ChromeRects {
Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob;
Rect velLabel;
// The sustain loop's enable. Immediately left of the channel toggle because it is the same
// class of control — a playback mode of the loaded capture — and because the run is
// right-anchored, so the title slot absorbs its width and the editor's floor does not move.
Rect loopOff;
Rect loopOn;
Rect chanMono;
Rect chanStereo;
Rect navBrowse;
+10 -8
View File
@@ -65,14 +65,16 @@ struct WaveformClaim {
enum class WaveformClaimant { kNone, kNode, kTab, kMarker };
// The overlay's cross-affordance arbitration: a contour node (or, mutually exclusively, a
// staged envelope's drag node — both feed the same `node` slot), the loop crossfade tab, and a
// marker's full-height column can all claim the same pixel. Hit gates a candidate out
// entirely; among the ones that hit, the SMALLEST nominal area wins — the marker column is the
// odd one out (its target is the whole overlay height), so it only wins where nothing narrower
// also claims the click. Ties go to whichever is checked first: node, then tab, then marker —
// no live geometry produces a tie except tab-vs-marker, which the tab correctly wins (see
// editor_input_waveform.cpp's mouseDownWaveform for the live constants). A control-click has no
// tab/marker meaning (they answer plain grabs only), so it resolves to the node whenever the
// staged envelope's drag node — both feed the same `node` slot), a mark's CAP, and a mark's
// full-height column can all claim the same pixel. Hit gates a candidate out entirely; among
// the ones that hit, the SMALLEST nominal area wins — the column is the odd one out (its target
// is the whole overlay height), so it only wins where nothing narrower also claims the click.
// Ties go to whichever is checked first: node, then tab, then marker — no live geometry
// produces a tie except tab-vs-marker, which the tab correctly wins (see
// editor_input_waveform.cpp's mouseDownWaveform for the live constants). Every mark's cap is
// one markerHandleRect, so the `tab` slot carries ONE nominal area however many marks feed it;
// which mark it resolves to is waveform_view's capAtPoint, not this. A control-click has no
// cap/column meaning (they answer plain grabs only), so it resolves to the node whenever the
// node is in the running, regardless of area.
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
const WaveformClaim& marker, SplineGesture gesture);
+65 -3
View File
@@ -24,13 +24,15 @@ OverlayArea waveformOverlayArea(const Rect& band) {
return OverlayArea{band.empty() ? Rect{} : band};
}
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
return (stereoMode && sourceChannels >= 2) ? LaneSplit::Stereo : LaneSplit::Single;
}
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) {
WaveformSurface s;
if (band.empty()) return s;
s.overlay = waveformOverlayArea(band);
const bool twoLanes = stereoMode && sourceChannels >= 2;
const WaveformLanes lanes =
waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single);
const WaveformLanes lanes = waveformLanes(band, resolveLaneSplit(stereoMode, sourceChannels));
s.upper = lanes.upper;
s.lower = lanes.lower;
// Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division
@@ -79,6 +81,66 @@ Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int
return Rect{left, r.y, right - left, std::min(kMarkerHandleHeight, r.height)};
}
int capAtPoint(const OverlayArea& area, std::int64_t frameCount, const WaveMarks& marks, int x,
int y) {
for (int i = kWaveMarkCount - 1; i >= 0; --i) {
if (!marks.present[i]) continue;
if (contains(markerHandleRect(area, frameCount, marks.frame[i]), x, y)) return i;
}
return -1;
}
bool markLabelLeftOfLine(WaveMark m) {
return m == WaveMark::kLoopEnd || m == WaveMark::kCrossfade;
}
Rect markLabelRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame,
bool leftOfLine, int textWidth) {
const Rect& r = area.rect;
if (r.empty() || textWidth <= 0 || textWidth > r.width) return Rect{};
const int h = std::min(kMarkLabelHeight, r.height - kMarkerHandleHeight);
if (h <= 0) return Rect{};
const int mx = frameToX(area, frameCount, frame);
int left = leftOfLine ? mx - kMarkLabelGap - textWidth : mx + kMarkLabelGap;
left = std::max(r.x, std::min(left, r.right() - textWidth));
return Rect{left, r.y + kMarkerHandleHeight, textWidth, h};
}
WaveMarkLabels layoutMarkLabels(const OverlayArea& area, std::int64_t frameCount,
const WaveMarks& marks, const int* textWidth, int promoted) {
WaveMarkLabels out;
if (textWidth == nullptr) return out;
// The promoted mark first, then draw order. Every label shares one row, so "would overlap
// one already placed" reduces to a horizontal span test.
int order[kWaveMarkCount + 1] = {promoted, 0, 1, 2, 3};
for (int slot = 0; slot < kWaveMarkCount + 1; ++slot) {
const int i = order[slot];
if (i < 0 || i >= kWaveMarkCount) continue;
if (!marks.present[i] || !out.box[i].empty()) continue;
const Rect box = markLabelRect(area, frameCount, marks.frame[i],
markLabelLeftOfLine(static_cast<WaveMark>(i)),
textWidth[i]);
if (box.empty()) continue;
bool clash = false;
for (int j = 0; j < kWaveMarkCount && !clash; ++j) {
clash = !out.box[j].empty() && box.x < out.box[j].right() &&
out.box[j].x < box.right();
}
if (!clash) out.box[i] = box;
}
return out;
}
int crossfadeWedgeHeight(int x0, int x1, int x) {
const int w = x1 - x0;
if (w <= 0 || x < x0 || x >= x1) return 0;
if (w == 1) return kCrossfadeWedgePx;
// Normalized over w - 1 so the LAST drawn column lands exactly on the peak, the same
// reason crossfadeWeight normalizes over crossfade - 1 (loop_span.h).
const int d = x - x0;
return (kCrossfadeWedgePx * d + (w - 1) / 2) / (w - 1);
}
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y) {
if (count <= 0 || frames == nullptr) return -1;
+74 -8
View File
@@ -11,6 +11,7 @@
#include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains
#include "core/instrument/ui/sample_bands.h" // LaneSplit (resolveLaneSplit's answer)
#include "core/audio/peaks.h" // AudioSample (float), Envelope
namespace reasampler::instrument::ui {
@@ -35,9 +36,16 @@ struct WaveformSurface {
// the band-stack allocator's kWaveformMinHeight floor.
};
// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source
// that has a second channel to show: a mono source under stereo mode is dual-mono, so a
// second lane would be the redundant duplicate single-lane mode exists to avoid.
// THE lane-split decision, free of any pixel geometry: two lanes need BOTH stereo mode and a
// source that has a second channel to show, since a mono source under stereo mode is dual-mono
// and a second lane would be the redundant duplicate single-lane mode exists to avoid. The one
// home of that rule — the meter's bar count is the SAME question and reads it here, rather than
// inferring it from a band rect it has no business knowing about.
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels);
// Resolves the surface for a waveform band, folding the split above and then measuring it
// against the band: WaveformSurface::laneCount can still report 1 for a Stereo split on a band
// too thin to divide, which is a geometry fact and not a second rule.
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels);
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
@@ -65,17 +73,75 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram
// area.x yields 0; right of area.right() yields frameCount.
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
// A marker's grab HANDLE: a tab riding the top of the overlay, centred on the marker's x and
// clipped into the area. Distinct from the full-height grab COLUMN markerAtPoint answers, so
// two markers that share a frame stay independently grabbable — the handle owns the top
// strip, the column owns everything below it. Without that split, first-in-draw-order wins
// every coincident tie and the loser can never be dragged apart again.
// A marker's grab HANDLE — THE CAP, in the mark grammar's vocabulary: a tab riding the top of
// the overlay, centred on the marker's x and clipped into the area. Distinct from the
// full-height grab COLUMN markerAtPoint answers, so two markers that share a frame stay
// independently grabbable — the handle owns the top strip, the column owns everything below
// it. Without that split, first-in-draw-order wins every coincident tie and the loser can never
// be dragged apart again. Every mark's cap is this ONE rect shape; only the glyph drawn inside
// it differs, which is what lets the claim arbitration see a single nominal cap area.
inline constexpr int kMarkerHandleHeight = 10;
// Same half-width as the column's own grab band on purpose: the handle is that same grab
// tolerance, just confined to the top strip, not an independent tuning.
inline constexpr int kMarkerHandleHalfWidth = kMarkerGrabWidth;
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
// --- The overlay's four marks -------------------------------------------------------------
// The marks the overlay carries, in DRAW and COLUMN-hit order. The shell's WaveMarker aliases
// this, so the drag router and the geometry below cannot disagree about an ordinal.
enum class WaveMark { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCrossfade = 3, kCount = 4 };
inline constexpr int kWaveMarkCount = static_cast<int>(WaveMark::kCount);
// Which marks are on screen and where. A mark that is not `present` is excluded from every
// answer below — with no loop set there is no crossfade mark to reach.
struct WaveMarks {
std::int64_t frame[kWaveMarkCount] = {0, 0, 0, 0};
bool present[kWaveMarkCount] = {false, false, false, false};
};
// Which mark's cap a point lands on, or -1. Caps resolve in the REVERSE of the column order
// markerAtPoint uses — crossfade, end, loop start, start — and that reversal is the whole
// separability argument: whichever mark of a coincident PAIR loses the cap still answers its
// own full-height column, and the crossfade, the one mark with no column at all, is first so
// nothing can shadow it. A coincident TRIPLE still strands its middle mark, exactly as the
// pre-cap tab/column split did.
int capAtPoint(const OverlayArea& area, std::int64_t frameCount, const WaveMarks& marks,
int x, int y);
// Labels sit in the row directly below the caps, beside the mark's line: START and LOOP to the
// right of it, END and XFADE to the left, so a label never crosses into the span it bounds.
inline constexpr int kMarkLabelHeight = 10;
inline constexpr int kMarkLabelGap = 3; // between the mark's line and its text
bool markLabelLeftOfLine(WaveMark m);
// The box `textWidth` px of label occupies for a mark at `frame`. Nudged inside the area rather
// than clipped — half a label reads as a different mark's — and empty when it cannot fit.
Rect markLabelRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame,
bool leftOfLine, int textWidth);
// The placed labels; an empty box is a label that is not drawn. `textWidth` is per mark, in the
// caller's own font (this module measures no text). `promoted` — a WaveMark ordinal, or -1 —
// is placed FIRST and so can never be the one suppressed: it is the mark the user is grabbing
// or hovering, i.e. the one they are asking about.
struct WaveMarkLabels {
Rect box[kWaveMarkCount];
};
WaveMarkLabels layoutMarkLabels(const OverlayArea& area, std::int64_t frameCount,
const WaveMarks& marks, const int* textWidth, int promoted);
// The crossfade region's peak edge-wedge height. The region draws as a wedge at the overlay's
// top and bottom edges and NEVER as a second fill: it now sits INSIDE the loop span, where a
// translucent fill would stack on the loop fill over an already-accepted under-floor contrast
// pair (see editor_paint_waveform.cpp).
inline constexpr int kCrossfadeWedgePx = 10;
// Wedge height at pixel column `x` over [x0, x1): zero at x0, kCrossfadeWedgePx at x1 - 1. The
// audible region and the ingredient ghost are the SAME ramp over the two spans the fade mixes,
// so one function draws both.
int crossfadeWedgeHeight(int x0, int x1, int x);
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
// its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
+3
View File
@@ -44,6 +44,9 @@ DropClass decideDropClass(int px, int py, const PanelClientRect& client,
case ReaperSurface::FxEmbed:
case ReaperSurface::Other:
return DropClass::Refuse;
case ReaperSurface::Count:
break; // the sentinel is not a surface; it falls to the refusal below
}
return DropClass::Refuse; // an unclassifiable surface still refuses visibly, never silently
}
+8
View File
@@ -104,6 +104,14 @@ inline constexpr double kLoopSpanFillAlpha = 0.20;
// body floor Font::Micro answers to.
inline constexpr double kCardNameScrimAlpha = 0.75;
// The waveform mark caption/label scrim: bg/base composited at this alpha UNDER the loop-state
// caption and the four mark labels, so text/dim (Font::Micro, body class) stays readable when
// the envelope's accent/primary peak reaches into the label's rect. Higher than
// kCardNameScrimAlpha because text/dim needs more cover than text/primary to clear the same
// 4.5:1 body floor against the same lime worst case — test_theme.cpp composes this exact value
// against accent/primary to pin the floor both roles answer to.
inline constexpr double kWaveformLabelScrimAlpha = 0.90;
// Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha
// is ignored — a translucent overlay's effective color is the caller's to compose first
// (compositeOver).
+30 -15
View File
@@ -1,8 +1,8 @@
#pragma once
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
// stage's normalized position to its normalized level, and the mid-segment inverse the
// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the
// overlay's forward map, and its inverse all read the same law rather than three copies.
// stage's normalized position to its normalized level, and that map's inverse (mid-segment is
// the special case). Header-only and dependency-free so the engine evaluator, the overlay's
// forward map, and its inverse all read the same law rather than three copies.
#include <cmath>
@@ -46,11 +46,20 @@ inline double clampCurve(double exponent) {
// and a dial swept through the centre cannot skip over it.
inline constexpr double kCurveKnobDetent = 0.01;
// The same travel with the detent NOT applied — the map for a writer that has no drag grid. A
// host automation lane delivers a NUMBER, not a gesture, so snapping it would not make the
// identity reachable (t == 0.5 already evaluates exp(0) == 1.0 exactly here); it would only
// destroy a near-neutral exponent the user set some other way.
inline double curveFromKnobNormUndetented(double norm) {
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
}
inline double curveFromKnobNorm(double norm) {
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
const double off = t - 0.5;
if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral;
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
return curveFromKnobNormUndetented(t);
}
inline double knobNormFromCurve(double exponent) {
@@ -58,19 +67,25 @@ inline double knobNormFromCurve(double exponent) {
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
}
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
// one value: both resolve through this law, not through each other.
inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); }
// The normalized level at an arbitrary segment position phi in (0,1), and its inverse. A
// knot's DRAWN x truncates to an integer, which lands it off phi = 0.5 whenever its segment's
// pixel span is odd; reading the knot's y through the phi its own x actually implies (rather
// than assuming 0.5) is what keeps the knot on the trace its own vertices draw.
inline double curveLevelAt(double phi, double exponent) { return curveMap(phi, clampCurve(exponent)); }
// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the
// Level -> exponent at phi: u = phi^p, so p = ln(u)/ln(phi). Out-of-domain u clamps to the
// exponent endpoints rather than producing a non-finite exponent.
inline double curveFromMidLevel(double midLevel) {
const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level
const double hi = curveMidLevel(kCurveMin); // largest
if (!(midLevel > lo)) return kCurveMax; // also catches NaN
if (midLevel >= hi) return kCurveMin;
return clampCurve(std::log(midLevel) / std::log(0.5));
inline double curveFromLevelAt(double phi, double level) {
const double lo = curveLevelAt(phi, kCurveMax); // smallest reachable level at this phi
const double hi = curveLevelAt(phi, kCurveMin); // largest
if (!(level > lo)) return kCurveMax; // also catches NaN
if (level >= hi) return kCurveMin;
return clampCurve(std::log(level) / std::log(phi));
}
// The segment-MIDPOINT (phi = 0.5) case — the knot's placement whenever its pixel span is
// even. Kept under its own name for the existing callers/tests that assume that case.
inline double curveMidLevel(double exponent) { return curveLevelAt(0.5, exponent); }
inline double curveFromMidLevel(double midLevel) { return curveFromLevelAt(0.5, midLevel); }
} // namespace reasampler::util
+161 -7
View File
@@ -9,9 +9,10 @@ two small identity/helper headers this directory owns outright
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_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
`limiter`, `meter_ballistics`, `master_meter`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants
@@ -68,19 +69,128 @@ scattered `#ifdef`s in the VST shell, except the one described below).
(`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`).
**The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and
which route a control takes is decided once, by the pure `isLiveDeckParam` / `liveCommitFor` pair
which route a control takes is decided once, by the pure `deckParamCommit` / `liveCommitFor` pair
(`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see
`core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale.
1. **Full reload**`reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap.
2. **Engine rebuild**`rebuildVoiceEngine`: same drain-slot swap around the already-decoded
`SampleData`. Voice count / mode / mono trigger.
3. **Live**`publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free
publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk.
publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. The
pure predicate splits this tier by WHO READS the published value (`Live` vs `NoteOnLatched`);
the route out of the editor is the same one either way.
The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
declared ahead of the instrument slots at that member in `reasampler_processor.h`.
**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The
pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is
`core/instrument/param` and is documented there; this directory only adapts it.
- **The blob stays authoritative.** `getState` serializes the model and nothing new is
persisted; the controller's own value list is a cache written FROM the model and never read
as truth. A load pushes the model into that cache through `syncParamsFromModel` WITHOUT
notifying the host, which the SDK requires.
- **`setInstrumentParams` is the notification funnel**, for the same reason it is already the
limiter mirror's: every writer of the parameter set — the editor's commits, `setState`, the
bake's adopt — passes through it, so no internal write can leave the host displaying, and on
next touch re-imposing, a superseded value. Master gain has its own funnel
(`setMasterGainLinear`) because it is the one exposed control that does not ride the
parameter set.
- **BOTH delivery channels are serviced, and the audio-side one is the normative one.**
`IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller
"should update the according GUI element(s) only" there, so nothing about the audio may depend
on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's
own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it
in `process()` while also implementing `setParamNormalized`. We do both, for the same reason.
- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams`
blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off
the audio thread and may allocate on the way; `process()` merges that block with the host's
automation points into `automationLive_`, which is what `SampleData::live` points at. Two
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
writers genuinely differ in thread.
### THE AUTHORITY MODEL — who may write a parameter's value, and until when
Two passes got this subtly wrong in opposite directions (the first delivered automation on the
wrong channel; the second made a point's authority permanent), because the model was in nobody's
head and nowhere in the tree. It is here, and the code follows it.
**`ReaSamplerProcessor::params_` — plus the two instance scalars beside it — is THE model, and
the single authority.** Everything else that holds these values is a cache or a courier:
| Writer | Authority begins | Authority ends |
|---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
| Limiter toggle (`setLimiterEnabled`) | the call | the call returns (it writes the model too, but through neither `commitLive` nor `commitAndReload`) |
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
Every writer above except the last two writes the model directly, so for those "authority ends"
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
— which is why its row states no authority window of its own. The automation lane is the only one
that cannot write directly: the SDK delivers it on the audio thread, where the model path
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
engine-facing block in place and is couriered to the UI thread, which folds it into the model on
the next tick.
**The hold is the bridge across that gap, and nothing more.** Between the point landing and the
fold — at most one UI tick — the model does not yet carry the value, so a model republish in that
window (any knob move) would revert the automated parameter until the lane's next point. The hold
re-applies the point over every merge to stop that. The instant the model carries the value, the
hold has no job and is **released**; from then on every writer above reaches the audio normally.
**Contention resolves BY RULE, not by timing.** A point outranks the model while the lane is
driving and the model has not caught up — which is VST3's own authority rule (a lane in
read/write mode outranks a plug-in-side set). It does NOT outrank a later restore, bake reset or
knob move, because by then the lane is no longer driving that value; the model is.
**Where it is enforced, and what fails if it stops holding.**
- The decision is the pure `core/instrument/param/param_merge`'s `mergeAutomation`;
`tests/test_param_merge.cpp`'s
`testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt` is the test — it asserts both halves,
including that a writer AFTER the release reaches the audio. A latch with no release fails it.
- The mechanism — the per-slot sequence the audio thread stamps and the UI thread answers, and
the acquire/release ordering that makes a release imply the publish is visible — is
`automation_channel.h`'s, at its two methods.
- **The release is stored LAST in `drainAutomationToModel`**, after `setInstrumentParams` and
`publishLiveParams`. Moving it earlier reintroduces a one-block revert.
- **`setState` therefore needs no ordering guarantee against the host's first parameter block.**
A lane that is driving re-applies over the restore; a lane that merely sent a point once, and
had it folded, does not — which is the correct reading of the SDK rule, and the one the second
pass got wrong.
**The editor's `params_` is a CACHE of the model, authoritative for one gesture only.** A commit
writes the WHOLE set back, and `notifyParamsFromModel` diffs it — so a stale copy would
`performEdit` superseded values the user never touched, which a lane in latch or write mode
records. The sync tick re-seeds it (past the drag guard) whenever `paramsGeneration_` has moved
under it: the automation fold, the host's generic panel, a state restore.
**Two independent gates keep a value-identical point off the per-voice fan-out**, and they cover
different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat
read-mode segment, where a host sends one point per block), and the merge publishes only when the
merged block differs from the last (a model republish that changed nothing). Neither is measured
against a performance budget — they are there because `VoiceEngine::applyLiveToActive` runs
`voice.applyLive` over every active voice, and neither case needs it.
- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
called from `getState`, the editor's sync tick, and `instrument_bake.cpp:125` — at the HEAD of
the bake chain, before the render, not its reload tail). The blob is authoritative, so a value
that never came back would be lost on save. The fold is suppressed from notifying the host —
the values came FROM it, and echoing them would let a lane in write mode re-record its own
playback.
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
is exposed, an invented map would hijack CCs the user's controller already sends, and
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not
implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence
site.
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that places a timeline item, or that
@@ -104,23 +214,67 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `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).
- `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 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. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. 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. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, the audio thread's queue drain and the UI thread's fold + release, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws, the formatter and the merge.
- `automation_channel.h` — the host automation lane's per-instance state and the mechanism of its authority lifetime: the audio thread's hold, the per-slot sequence it stamps, the UI thread's release answer, and the acquire/release ordering that makes a release imply the model publish is visible. The MODEL it enforces is the Authority section above; the pure decision it feeds is `core/instrument/param/param_merge`. Internal to this TU family.
- `processor_snapshot.h` — the two namespace-scope aggregates the processor hands across its thread boundary: `LoadedInstrument` (the decoded capture plus the engine playing it, swapped through the drain slot) and `MasterBusMeter` (what the audio thread publishes per block for the editor's meter). Split out of `reasampler_processor.h` on `editor_interaction.h`'s grounds — neither is behaviour.
- `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_interaction.h`the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
- `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, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — 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.)*
## Gotchas
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
false premise.** It was described as ONE class declaration; it also carried two namespace-scope
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
RT-discipline constraints, which the comment conventions name as keep-worthy.
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- **The limiter toggle splits its commit: the sound is inline, the HOST NOTIFICATION arms.**
Its commit needs the host's `restartComponent(kLatencyChanged)`; a host that services that
synchronously runs `setActive(false)`/`setActive(true)`, which rebuilds this instance's voice
state — running that inline from `WM_LBUTTONDOWN` would nest it in a mouse handler. So the
click commits the parameter set, the audio-thread mirror and the latency reader at once, and
`setInstrumentParams` only ARMS a pending restart that `flushLatencyRestart` delivers. The
editor's sync tick is the general drain and sits AFTER the drag guard with the bake (the
restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a reload
would); `setState` flushes at its own tail because it can commit with no editor open, and the
bake's adopt does so only to save a tick — its chain runs from that same tick. The arm is
judged against the LAST ANNOUNCED enable, so toggling back to it inside one tick costs no
restart at all.
**The residual:** between the commit and the flush the host's delay compensation is out of
step with the plugin by `limiterLookaheadSamples` (2 ms — `round(0.002 · rate)`, the
detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick.
Narrowing it further means a second deferral mechanism (a posted window message) rather than
the tick — deliberately not built.
- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run
BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but
the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
**The published block state is therefore ACCUMULATED, not sampled**: at 48 kHz / 512 frames
~47 blocks elapse per tick, so the processor folds a per-channel max and a min limiter gain
across them and `masterBusMeter()` clears the accumulators as it reads. A plain overwriting
store displayed one block in ~47 and lost the rest — the specified "a peak displays on the
first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING,
so exactly one caller may hold it; the embed strip reads its own non-consuming
`embedActivityLevel()`. **The tick's FIRST read is discarded**, because that caller is the
only consumer: with no editor open the accumulators hold everything since the instance was
created, and advancing off them would open the meter at the session's loudest peak. The clip
latch is not discarded with them — it is a latch the user clears. A meter-rate timer remains a
separate change and is not in.
- 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. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+5 -2
View File
@@ -46,6 +46,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
reasampler_processor.cpp
processor_state.cpp
processor_reload.cpp
instrument_params.cpp
# The editor family is split on the Sample face's band axis: session/bridge state,
# param plumbing plus the shared band-layout resolve, then paint and input in
# matching sets, plus the two band-independent surfaces and the platform TU.
@@ -86,11 +87,13 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sampler_core sample_map component_state_io capture_paths embed_strip app_version
capture_browser keyboard_strip sample_bands sample_chrome
waveform_view bank_sync browser_scroll param_slider tooltip
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
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
bake_hold
param_id param_units param_format param_live param_merge
limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa
curve_tessellate
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
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
+158
View File
@@ -0,0 +1,158 @@
// automation_channel.h — the host automation lane's per-instance state, and the ONE place its
// AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until
// the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md
// states the model; `core/instrument/param/param_merge` is the pure decision this feeds.
// The release itself is observed the NEXT BLOCK, not the instant it happens — see
// refreshReleases().
#pragma once
#include <atomic>
#include <cassert>
#include <cstddef>
#include <cstdint>
#include "core/instrument/param/param_merge.h"
namespace reasampler::vst {
class ReaSamplerProcessor; // the one class that legitimately mints a ReleaseProof
// The DeckParam ordinal space — what the automation slots and the notification diff both index.
inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlots;
class AutomationChannel {
public:
// Evidence release() below requires: the model has actually caught up with the
// point being released. The two factories are named for the case they cover —
// `fromPublish` for an observed model republish, `noRepublishNeeded` for the two
// cases drainAutomationToModel finds nothing to publish (the fold left the model
// unchanged, or no engine exists yet to read the live block). This is NOT a full
// compile-time proof of ordering: `noRepublishNeeded` still lets `ReaSamplerProcessor`
// claim the exemption with no publish at all. What it enforces structurally is that a
// caller cannot spell release()'s argument without naming, by which factory it called,
// WHICH exemption it is claiming — greppable by a reviewer, not silently inline — and
// both factories are restricted to the one class that legitimately needs either.
// The private, user-provided default constructor plus the deleted copy constructor
// close the two ways `{}` and copy-reuse would otherwise fabricate one for free; see
// the two definitions below for which C++17 rule each closes.
class ReleaseProof {
private:
friend class ReaSamplerProcessor;
static ReleaseProof fromPublish(std::uint32_t before, std::uint32_t after) {
// publish() advances the generation BY CONSTRUCTION (gen + 2, skipping 0 on wrap —
// live_params.h), so `after == before` is unreachable from this, its one call site,
// short of ~2^31 publishes wrapping exactly onto `before`. It also cannot see the
// actually-reachable failure mode, a caller falsely claiming noRepublishNeeded() —
// that path never runs this function. DEBUG-ONLY on purpose: an unconditional abort
// here would take down a musician's whole REAPER session, unsaved work on every other
// track and plugin included, for a condition this call site cannot produce; a debug
// build (where the test suite runs) is where a future regression in publish()'s
// advance guarantee should be caught.
assert(after != before && "publish() must advance the generation");
return ReleaseProof{};
}
static ReleaseProof noRepublishNeeded() { return ReleaseProof{}; }
// User-PROVIDED (a body, not `= default`): under C++17 a class with no data
// members and only a user-DECLARED (not user-provided) default constructor is
// still an aggregate, because C++17's aggregate rule excludes private data
// members only, not private constructors — so `ReleaseProof{}` would perform
// aggregate init and never call this. A user-provided constructor defeats that.
// (C++20's P1008 closes the same hole at the language level; this project is
// pinned to C++17 — CMakeLists.txt:28 — so the class must close it itself.)
ReleaseProof() {}
// Closes the other free-mint path: the implicit copy constructor is public by
// default, so one legitimately-minted proof cached in a member could be replayed
// by any later caller with no new publish behind it. A user-declared copy
// constructor (deleted or not) also suppresses the implicit move constructor, so
// no move-based replay path opens in its place — `release()` below takes this by
// const reference for exactly that reason, rather than needing one back.
ReleaseProof(const ReleaseProof&) = delete;
};
// --- AUDIO THREAD -------------------------------------------------------------------
// A point landed for `slot`. `ridesTheBlock` is false for a control that reaches the audio
// beside the live block (master gain, whose route is the processor's own atomic): such a
// point takes no hold and does not make the block dirty, so a lane on it alone cannot drive
// the per-voice fan-out every block for a value the block does not carry.
//
// Answers whether the block MOVED, which is what makes the merge conditional. A repeat of a
// standing hold moves nothing and is dropped whole — no hold rewrite, no UI publish — because
// it would only make the fold rewrite the model with the value already in it.
bool land(std::size_t slot, double normalized, bool ridesTheBlock) {
if (ridesTheBlock && !instrument::param::automationPointMoves(slots_[slot], normalized)) {
return false;
}
if (ridesTheBlock) {
slots_[slot].norm = normalized;
slots_[slot].held = true;
}
published_[slot].store(normalized, std::memory_order_relaxed);
// The sequence is stored LAST and with release: the fold reads it FIRST and only then
// trusts the value beside it.
seq_[slot].store(seq_[slot].load(std::memory_order_relaxed) + 1,
std::memory_order_release);
any_.store(true, std::memory_order_release);
return ridesTheBlock;
}
// Refreshes each held slot's release answer. Runs only inside process()'s merge branch, so a
// release lands the NEXT BLOCK after the UI thread makes it, never the same instant — benign
// on the `moved` path because publishLiveParams always bumps the generation that branch
// checks, so the next block is guaranteed to run this. The `noRepublishNeeded()` path has no
// publish and no generation bump to guarantee that — land() drops a static lane's repeats, so
// `moved` is false there too — but it is equally benign: the model already carries the value
// (that is why nothing published), so the release is simply observed whenever the generation
// next moves under ANY writer, not specifically this one. Must run BEFORE the model block is
// read: the acquire here synchronizes with the UI thread's release store, which it makes only
// AFTER republishing the model — so a slot seen released is one whose value any block read
// after this point is guaranteed to already carry.
void refreshReleases() {
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
if (!slots_[i].held) continue;
slots_[i].folded = folded_[i].load(std::memory_order_acquire) ==
seq_[i].load(std::memory_order_relaxed);
}
}
instrument::param::AutomationSlot* slots() { return slots_; }
// --- UI THREAD ----------------------------------------------------------------------
// True when at least one point has landed since the last drain.
bool takePending() { return any_.exchange(false, std::memory_order_acquire); }
// The value and sequence of `slot`'s unfolded point, or false when there is nothing new.
bool takeSlot(std::size_t slot, double& value, std::uint32_t& seq) const {
seq = seq_[slot].load(std::memory_order_acquire);
if (seq == folded_[slot].load(std::memory_order_relaxed)) return false;
value = published_[slot].load(std::memory_order_relaxed);
return true;
}
// Releases `slot`'s hold. The `ReleaseProof` argument is the enforcement: it can only be
// constructed once the model carrying this point has been republished (or shown not to need
// it), so a caller earlier in that ordering has no value to pass. By const reference, not
// value: the copy constructor is deleted (see ReleaseProof), and the one caller releasing a
// whole fold's worth of slots passes the same proof through this repeatedly.
void release(std::size_t slot, std::uint32_t seq, const ReleaseProof&) {
folded_[slot].store(seq, std::memory_order_release);
}
private:
instrument::param::AutomationSlot slots_[kDeckParamSlots] = {}; // audio thread only
std::atomic<double> published_[kDeckParamSlots] = {};
std::atomic<std::uint32_t> seq_[kDeckParamSlots] = {}; // written by the audio thread
std::atomic<std::uint32_t> folded_[kDeckParamSlots] = {}; // written by the UI thread
std::atomic<bool> any_{false}; // makes the UI thread's idle drain a single exchange
};
// The publication atomics above are read on the audio thread; a locked implementation would be a
// hidden mutex on it. Structural rather than assumed, for a class whose thesis is RT discipline.
static_assert(std::atomic<double>::is_always_lock_free,
"the automation publication must be lock-free — the audio thread writes it");
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
"the automation sequence must be lock-free — the audio thread writes it");
} // namespace reasampler::vst
+93 -116
View File
@@ -1,23 +1,25 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set
// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor
// selection names — is editor_models. Value logic only: no painting, no window plumbing.
// preview velocity — plus each knob's plain value and its label), and the node-drag clamp
// bounds. The parameter-set half is the pure `deck_values` module. The orthogonal half — which
// stored struct each editor selection names — is editor_models. Value logic only.
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <cmath> // isfinite (the gain's -inf label)
#include <cstdint>
#include <cstdio> // snprintf (deck value labels)
#include <string>
#include <vector>
#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/param/param_format.h" // THE formatter every value label reads through
#include "core/instrument/param/param_id.h" // whether a control has a parameter row at all
#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_values.h" // the parameter-set binding + its ms units
#include "core/instrument/ui/deck_values.h" // the parameter-set binding
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
@@ -33,18 +35,12 @@ using instrument::ui::deckHeight;
using instrument::ui::kDeckKnobSize;
using instrument::ui::kPad;
using instrument::ui::deckParamNorm;
using instrument::ui::formatEnvTimeMs;
using instrument::ui::kEnvTimeMaxSeconds;
using instrument::ui::kKeyTrackMax;
using instrument::ui::resetDeckParam;
using instrument::ui::sampleDeckGroups;
using instrument::ui::setDeckParam;
using instrument::engine::formatMasterGainLabel;
using instrument::engine::masterGainLinearFromNorm;
using instrument::engine::masterGainNormFromLinear;
using instrument::engine::filter::filterCutoffHzFromNorm;
using instrument::engine::filter::filterDriveDepthFromNorm;
using instrument::engine::filter::filterQFromNorm;
using util::clamp01;
namespace {
@@ -75,10 +71,10 @@ double curveExponentFor(int id, const PlaySeconds& play) {
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
// chrome interior, and the deck descriptors can never be derived three different ways.
// The deck's own wrapped height is the only interior measurement the allocator needs.
// The deck's own height is the only interior measurement the allocator needs.
FaceLayout fl;
fl.deckDescs = sampleDeckGroups(params_.play.playMode);
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs));
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
return fl;
}
@@ -122,7 +118,7 @@ double ReaSamplerEditor::deckControlNorm(int id) const {
if (id == kBakeHoldKnobId) return bakeHoldNorm();
switch (static_cast<ParamControl>(id)) {
case ParamControl::kKeyTrack:
return clamp01(params_.keyTrack / kKeyTrackMax);
return instrument::ui::keyTrackNormFrom(params_.keyTrack);
case ParamControl::kVoiceCount:
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
@@ -142,6 +138,48 @@ instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const {
play.filterSpline.mode == EnvMode::Spline};
}
bool ReaSamplerEditor::loopControlsLive() const {
return effectivePlayMode(params_.play) == PlayMode::Gate;
}
instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m) const {
using instrument::ui::WaveMark;
instrument::ui::WaveMarks w;
w.frame[static_cast<int>(WaveMark::kStart)] = m.start;
w.frame[static_cast<int>(WaveMark::kLoopStart)] = m.loopStart;
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
// The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it
// to sit on, and no length to drag.
w.present[static_cast<int>(WaveMark::kStart)] = true;
w.present[static_cast<int>(WaveMark::kLoopStart)] = true;
w.present[static_cast<int>(WaveMark::kLoopEnd)] = true;
w.present[static_cast<int>(WaveMark::kCrossfade)] = m.hasLoop;
return w;
}
instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const {
using instrument::ui::WaveMark;
instrument::ui::WaveMarks w = waveMarksFor(m);
if (!loopControlsLive()) {
w.present[static_cast<int>(WaveMark::kLoopStart)] = false;
w.present[static_cast<int>(WaveMark::kLoopEnd)] = false;
w.present[static_cast<int>(WaveMark::kCrossfade)] = false;
}
return w;
}
void ReaSamplerEditor::setLoopEnabled(bool on) {
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
if (frames <= 0) return;
SetupMarkers m = pickedMarkers(frames);
if (m.hasLoop == on) return; // a no-op commit would buy a re-decode for nothing
m.hasLoop = on;
applyMarkers(m);
commitAndReload();
}
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
if (!processor_) return;
norm = clamp01(norm);
@@ -178,107 +216,45 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
}
}
std::string ReaSamplerEditor::deckValueLabel(int id) const {
char buf[24];
buf[0] = '\0';
const PlaySeconds& play = params_.play;
switch (id < 0 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack:
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kHold:
formatEnvTimeMs(play.adsr.holdSeconds, buf, sizeof(buf)); break;
case ParamControl::kDecay:
formatEnvTimeMs(play.adsr.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break;
case ParamControl::kRelease:
formatEnvTimeMs(play.adsr.releaseSeconds, buf, sizeof(buf)); break;
case ParamControl::kTrigLength:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
case ParamControl::kTrigAttack:
formatEnvTimeMs(play.trigAhd.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break;
case ParamControl::kTrigDecay:
formatEnvTimeMs(play.trigAhd.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvAttack:
formatEnvTimeMs(play.pitchEnv.shape.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break;
case ParamControl::kPitchEnvDecay:
formatEnvTimeMs(play.pitchEnv.shape.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
case ParamControl::kVoiceCount:
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
case ParamControl::kMasterGain:
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
// Filter readouts run the stored normalized positions back through the module's OWN
// laws, so what the label says is what the kernel is solved for.
case ParamControl::kFilterMorph: {
const double m = play.filter.settings.morphNorm;
snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0);
break;
}
case ParamControl::kFilterCutoff: {
const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm);
if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f);
else snprintf(buf, sizeof(buf), "%.0fHz", hz);
break;
}
case ParamControl::kFilterQ:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
break;
case ParamControl::kFilterDrive:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(filterDriveDepthFromNorm(play.filter.settings.driveNorm)));
break;
case ParamControl::kFilterModAmt:
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
case ParamControl::kFilterVel:
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
case ParamControl::kFilterKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
case ParamControl::kFilterEnvAttack:
formatEnvTimeMs(play.filter.env.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvHold:
formatEnvTimeMs(play.filter.env.holdSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvDecay:
formatEnvTimeMs(play.filter.env.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
case ParamControl::kFilterEnvRelease:
formatEnvTimeMs(play.filter.env.releaseSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterTrigAttack:
formatEnvTimeMs(play.filter.trigEnv.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break;
case ParamControl::kFilterTrigDecay:
formatEnvTimeMs(play.filter.trigEnv.decaySeconds, buf, sizeof(buf)); break;
// Every curve exponent reads the same way: the neutral shows as 1.00.
case ParamControl::kAttackCurve:
case ParamControl::kDecayCurve:
case ParamControl::kReleaseCurve:
case ParamControl::kTrigAttackCurve:
case ParamControl::kTrigDecayCurve:
case ParamControl::kPitchEnvAttackCurve:
case ParamControl::kPitchEnvDecayCurve:
case ParamControl::kFilterEnvAttackCurve:
case ParamControl::kFilterEnvDecayCurve:
case ParamControl::kFilterEnvReleaseCurve:
case ParamControl::kFilterTrigAttackCurve:
case ParamControl::kFilterTrigDecayCurve:
snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play));
break;
default:
// The chrome knobs (preview velocity, bake Hold) are labeled at their own call
// site; nothing else here.
break;
double ReaSamplerEditor::deckPlainValue(int id) const {
const auto deck = static_cast<instrument::ui::DeckParam>(id);
// A curve exponent is read off its stored field, never round-tripped through the knob law:
// that law's centre detent snaps anything near-neutral back to exactly 1.0, so a round trip
// would misreport a stored exponent that isn't neutral as 1.00. The host has only the norm
// and therefore cannot make this distinction — param/CLAUDE.md records the divergence.
if (instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent) {
return curveExponentFor(id, params_.play);
}
return std::string(buf);
return instrument::param::toPlain(deck, deckControlNorm(id));
}
std::string ReaSamplerEditor::deckValueLabel(int id) const {
if (id < 0 || id >= static_cast<int>(ParamControl::kCount)) return {};
const auto deck = static_cast<instrument::ui::DeckParam>(id);
// The one deck knob with no plain-value layer at all: an already-integer count.
if (deck == ParamControl::kVoiceCount) {
char buf[24];
snprintf(buf, sizeof(buf), "%d", voiceCount_);
return std::string(buf);
}
if (instrument::param::paramIdFor(deck) == 0) return {};
// The digits come from the ONE formatter; everything the editor adds around them is static
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
const double plain = deckPlainValue(id);
char digits[24];
instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits));
const auto kind = instrument::param::unitKindFor(deck);
const char* caret =
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
// The gain at true silence reads "-inf", not "-infdB": there is no decibel value there.
if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) {
return std::string(digits);
}
// The stage times are the one category that carries a space before its unit, and always did —
// this surface's own typography, not the host's (ParameterInfo::units is the bare string).
const char* gap = kind == instrument::param::UnitKind::Time ? " " : "";
return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck);
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
@@ -295,8 +271,9 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
params_.keyTrack = clamp01(value) * kKeyTrackMax;
// keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than
// the PlaySeconds binding.
params_.keyTrack = instrument::ui::keyTrackFromNorm(value);
} else {
applyControl(id, params_.play, value, segment);
}
+9 -3
View File
@@ -85,6 +85,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
invalidate();
}
if (drag_ == DragKind::kNone) return;
// Stack RAII rather than a call at each exit: this handler leaves through several early
// returns, and the release commit's final performEdit must land INSIDE the bracket the grab
// opened while the bracket itself may not outlive the handler on any path.
struct GestureClose {
ReaSamplerProcessor* p;
~GestureClose() { if (p) p->endParamGesture(); }
} gestureClose{processor_};
const DragKind kind = drag_;
const int paramId = dragParamId_;
const int curveIdx = curvePointIndex_;
@@ -107,9 +114,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
// directly. Voice count: the label/needle tracks live during the drag but the engine
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
const bool deckTransient =
kind == DragKind::kDeckKnob &&
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain));
kind == DragKind::kDeckKnob && deckKnobIsProcessorSide(paramId);
if (kind == DragKind::kScrollThumb || deckTransient) {
// Commit the voice count now that the drag is complete (one rebuild per full drag).
if (deckTransient && processor_ &&
@@ -170,6 +175,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
const FaceLayout fl = faceLayout(w, hgt);
h = hoverChrome(fl, x, y);
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y);
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverWaveform(fl, x, y);
}
if (h != hover_) {
+2 -2
View File
@@ -98,8 +98,8 @@ void ReaSamplerEditor::dragBrowse(int x, int y) {
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
+23 -4
View File
@@ -1,6 +1,6 @@
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
// the preview-velocity knob grab, the channel toggle, and the piano strip's root grab plus
// its live drag. Windows-only.
// the preview-velocity knob grab, the loop enable, the channel toggle, and the piano strip's
// root grab plus its live drag. Windows-only.
#include "shell/instrument/reasampler_editor.h"
@@ -73,6 +73,21 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
invalidate();
return true;
}
// The loop enable. Inert (not hidden) outside Gate: that refusal comes from the engine and
// no click can talk it out of it — unlike the user's own off, which the marks themselves
// still offer to reverse.
if (loopControlsLive()) {
if (contains(cr.loopOff, x, y)) {
setLoopEnabled(false);
invalidate();
return true;
}
if (contains(cr.loopOn, x, y)) {
setLoopEnabled(true);
invalidate();
return true;
}
}
if (contains(cr.chanMono, x, y)) {
channelMode_ = ChannelMode::Mono;
processor_->setChannelMode(ChannelMode::Mono);
@@ -137,8 +152,8 @@ void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
@@ -146,6 +161,10 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
if (loopControlsLive()) {
if (contains(cr.loopOff, x, y)) return {HoverKind::kLoopOff, -1};
if (contains(cr.loopOn, x, y)) return {HoverKind::kLoopOn, -1};
}
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
if (!cr.rootStrip.empty()) {
+5 -5
View File
@@ -47,9 +47,9 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
// Alt-click delete is retired (the spec's right-click supersedes it — one grammar, no
// migration on either side): every gesture here routes through the shared resolver.
const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
const SplineEdit edit = resolveSplineEdit(
editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y);
const SplineGesture gesture =
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
const SplineEdit edit = resolveSplineEdit(editedCurve(), box, gesture, x, y);
if (edit.kind == SplineEditKind::kToggleHard) {
if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
return;
@@ -114,8 +114,8 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
/*addOnEmptySpace=*/false);
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
if (!contains(pl.curveBox, x, y)) return {};
+56 -5
View File
@@ -6,6 +6,7 @@
#ifdef _WIN32
#include "core/instrument/ui/deck_values.h" // snapDeckParamNorm (Shift's whole-unit table)
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
#include "shell/instrument/editor_internal.h"
@@ -63,6 +64,19 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
applyParamControl(hit.id, 0.0, hit.segment);
commitAndReload();
break;
case ParamControl::kLimiterEnable: {
const bool on = (hit.segment == 1);
if (on != params_.limiterEnabled) {
params_.limiterEnabled = on;
// Commits the audible state and the persisted state together, here, because
// this is a control the user A/Bs. The funnel only ARMS the host's latency
// restart — the sync tick delivers it — so nothing on this path calls into
// the host from inside a mouse handler.
processor_->setLimiterEnabled(on);
}
invalidate();
break;
}
default: {
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
// and the three env-mode toggles).
@@ -79,6 +93,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
}
return true;
}
if (hit.kind == DeckHitKind::Column) {
// The meter's ONLY gesture: clear the latched clip cap. Both latches go — the audio
// thread's is what the next tick would otherwise re-latch the UI's from.
masterMeter_ = clearMasterMeterClip(masterMeter_);
processor_->clearMasterBusClip();
invalidate();
return true;
}
if (hit.kind == DeckHitKind::Knob) {
// Knobs of a disabled group are drawn but inert.
if (deckKnobDisabled(hit.id)) return true;
@@ -97,11 +119,18 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
dragInnerCellId_ = inner ? hit.id : -1;
dragKnobStartValue_ = deckControlNorm(dragParamId_);
dragMods_ = dragModifiers();
// Processor-side knobs (voice count / master gain) are transient live writes with no
// parameter-set mutation, so they need no rollback snapshot.
dragStartParams_ = params_;
dragStartX_ = x;
dragStartY_ = y;
// Opens the host's edit bracket for the whole gesture, so a host in touch or latch mode
// records one continuous edit rather than a burst of one-point ones. Closed on release
// and on capture-lost; a control with no parameter row is a no-op inside the processor.
if (processor_) {
processor_->beginParamGesture(static_cast<instrument::ui::DeckParam>(dragParamId_));
}
invalidate();
}
// The deck band swallows its own clicks either way — no fall-through to the waveform.
@@ -150,20 +179,42 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
// value at grab (up = increase), so the value tracks relative motion and never jumps on
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
(void)x;
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
const DragModifiers mods = dragModifiers();
if (mods != dragMods_) {
// Re-anchor (see dragMods_). Reading the anchor back off the control also means a Shift
// RELEASE re-anchors from the SNAPPED value, so the knob does not spring back.
dragKnobStartValue_ = deckControlNorm(dragParamId_);
dragStartY_ = y;
dragMods_ = mods;
}
double norm = knobDragValue(dragKnobStartValue_, y - dragStartY_, mods);
// The preview-velocity sentinel (-2) and the discrete controls have no whole unit to snap to.
if (mods.shift && dragParamId_ >= 0) {
norm = snapDeckParamNorm(static_cast<DeckParam>(dragParamId_), norm);
}
applyDeckKnob(dragParamId_, norm);
// A live control is delivered on every move, not only on release — that is the whole
// point: the note already sounding tracks the hand on the knob.
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
// point: the note already sounding tracks the hand on the knob. The processor-side knobs
// are skipped because applyDeckKnob already wrote them straight through; commitLive would
// only re-push an unchanged parameter set. The release and capture-lost paths ask this same
// question; the reset path never reaches the commit for them at all.
if (!deckKnobIsProcessorSide(dragParamId_) &&
dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) {
commitLive();
}
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
const Rect& band = fl.bands.decks;
if (!contains(band, x, y)) return {};
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {};
// The meter reports its own state continuously; a hover on it would only mean "the clip
// cap is clearable", which the cap's presence already says.
if (dh.kind == DeckHitKind::Column) return {};
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
if (dh.kind == DeckHitKind::Knob && dh.inner &&
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
+75 -26
View File
@@ -35,9 +35,8 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
const DeckEnableState gates = deckEnableState();
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
? SplineGesture::kControlLeft
: SplineGesture::kLeft;
const SplineGesture gesture =
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
// The staged envelope's draggable node and the drawn contour's node are mutually exclusive
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
@@ -56,16 +55,21 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
const SetupMarkers m = pickedMarkers(frames);
const WaveMarks grabbable = grabbableMarks(m);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
// Three affordances can claim the same pixel: a node (the staged envelope's or the drawn
// contour's — a small fixed pick box either way), the crossfade tab (a small clipped
// top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column
// split already keeps the tab apart from ITS OWN column; this is the cross-affordance case
// on top of that). resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures
// each claimant's own NOMINAL target area and lets the smallest hit win, since a fixed check
// order shadows whichever one loses the tie — this seam regressed twice from exactly that
// fix. Never add here (kAdd is only tried once nothing else has claimed the click, below).
// contour's — a small fixed pick box either way), a mark's CAP (a small clipped top-strip
// tab), and a mark's full-height grab column (waveform_view.h's cap-vs-column split already
// keeps a cap apart from ITS OWN column; this is the cross-affordance case on top of that).
// resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures each claimant's
// own NOMINAL target area and lets the smallest hit win, since a fixed check order shadows
// whichever one loses the tie — this seam regressed twice from exactly that fix. Giving every
// mark a cap changed WHICH mark the cap slot resolves to AND the slot's own nominal area
// (every cap is one markerHandleRect, so it moved from the old clipped-actual measure — 60 at
// frame 0 — to the nominal 110); the `cap < node < column` ordering held anyway, because 110
// is still under the node's fixed pick-box area. Never add here (kAdd is only tried once
// nothing else has claimed the click, below).
WaveformClaim node;
if (envNodeHit.hit) {
constexpr std::int64_t side = 2 * kNodeGrabRadius + 1;
@@ -82,18 +86,22 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
}
const Rect tabRect =
m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{};
const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y))
? WaveformClaim{true, static_cast<std::int64_t>(tabRect.width) *
tabRect.height}
: WaveformClaim{};
const int capHit = capAtPoint(overlay, frames, grabbable, x, y);
const WaveformClaim tab =
(capHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerHandleHalfWidth + 1) *
kMarkerHandleHeight}
: WaveformClaim{};
// Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at
// frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box
// corner. Both overestimate in the direction that already produces the intended winner, so
// the arbitration runs on NOMINAL area, not the measured hit-testable pixel count.
const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
const int markerHit =
(grabbable.present[static_cast<int>(WaveMark::kLoopStart)]
? markerAtPoint(overlay, frames, markerFrames, 3, x, y)
// In Trigger only START answers a column, and it is index 0 of the same array.
: markerAtPoint(overlay, frames, markerFrames, 1, x, y));
const WaveformClaim marker =
(markerHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) *
@@ -107,14 +115,19 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
envNode_ = envNodeHit.node;
dragStartX_ = x;
dragStartY_ = y;
dragMods_ = dragModifiers();
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
// Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or
// knot drag can move more than one exposed parameter and the grab cannot know
// which; the release and capture-lost paths close it generically.
if (processor_) processor_->beginParamGestureLatch();
return true; // node moves once the cursor drags
}
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
case WaveformClaimant::kTab:
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
beginMarkerDrag(static_cast<WaveMarker>(capHit), m, frames, x);
return true;
case WaveformClaimant::kMarker:
beginMarkerDrag(static_cast<WaveMarker>(markerHit), m, frames, x);
@@ -179,6 +192,28 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
return true;
}
HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) {
// Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer
// can be "on". A hovered mark promotes its own label past the suppression rule.
//
// Rejected on the band's own rect FIRST, before anything expensive: this runs on every
// WM_MOUSEMOVE, and with no loop override set pickedMarkers costs a bridge read plus a bank
// parse. Every cap lives in the top kMarkerHandleHeight of the band, so that strip is the
// only place the answer can be anything but a miss.
const Rect& band = fl.bands.waveform;
if (band.empty() || x < band.x || x >= band.right() || y < band.y ||
y >= band.y + kMarkerHandleHeight) {
return {};
}
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
if (frames <= 0) return {};
const OverlayArea overlay = waveformOverlayArea(band);
const int cap = capAtPoint(overlay, frames, grabbableMarks(pickedMarkers(frames)), x, y);
if (cap < 0) return {};
return {HoverKind::kWaveMark, cap};
}
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
std::int64_t frames, int x) {
drag_ = DragKind::kWaveMarker;
@@ -213,9 +248,20 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
totalSeconds, envClampBounds(), dx,
y - dragStartY_);
const DragModifiers mods = dragModifiers();
if (mods != dragMods_) {
// Re-anchor (see dragMods_): the node's CURRENT params and the cursor's current
// position become the origin, so the flip changes only the rate. Re-packing the
// grab envelope is what makes that true for the delta this resolver measures.
dragStartEnv_ =
packEnvelope(overlayEnv_, params_.play, frames, params_.startPoint.value_or(0));
dragStartX_ = x;
dragStartY_ = y;
dragMods_ = mods;
}
const StageEnvelope edited =
resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds, envClampBounds(),
x - dragStartX_, y - dragStartY_, mods);
unpackEnvelope(overlayEnv_, edited, params_.play);
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
@@ -232,7 +278,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd,
dragStartMarkers_.loopStart -
dragStartMarkers_.loopEnd -
dragStartMarkers_.crossfade};
std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx);
@@ -240,13 +286,16 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
// frames — no host types, no file I/O. The crossfade handle is exempt: it sets a fade
// LENGTH, and the whole point of the fade is that its edges need no zero crossing.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) {
if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a LOOP marker turns the
// enable on — a grab implies intent to loop, and it is what teaches the chrome toggle by
// demonstration. Dragging START does not: it is live in both modes and says nothing about
// the loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
@@ -256,8 +305,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
} else if (waveMarker_ == WaveMarker::kLoopEnd) {
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
} else { // kLoopXfade — the handle sits at loopStart - crossfade, so left lengthens it
m.crossfade = (std::max)(std::int64_t{0}, m.loopStart - newFrame);
} else { // kCrossfade — the handle sits at loopEnd - crossfade, so left still lengthens it
m.crossfade = (std::max)(std::int64_t{0}, m.loopEnd - newFrame);
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
+55
View File
@@ -0,0 +1,55 @@
// editor_interaction.h — the Sample editor's INTERACTION VOCABULARY: what a drag can be
// editing, and what the pointer can be over. Two catalogues of the editor's interactive
// surface, produced by the input TUs and read by the paint TUs; neither is behaviour, which is
// why they are named here rather than buried inside the editor class between its paint and
// layout declarations. Internal to the reasampler_editor TU family, like editor_internal.h.
#pragma once
namespace reasampler::vst {
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their grabbed
// item in the editor's waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored knob
// drag (control in dragParamId_, grab value in dragKnobStartValue_). kSplineNode is the
// overlay's peer of kCurveNode: the same VelocityCurve point drag, over the waveform overlay's
// box and the overlay-active envelope's contour rather than the popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kSplineNode, kDeckKnob };
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE.
enum class HoverKind {
kNone,
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kBack, // the Browse "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kBrowseConfirm, // the Browse modal "Load" confirm button
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kLoopOff, // the loop enable's Off segment
kLoopOn, // the loop enable's On segment
kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
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
kPopupClose, // the curve popup's Close (x) button
};
// `index` disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1
// when not applicable.
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
} // namespace reasampler::vst

Some files were not shown because too many files have changed in this diff Show More