1139 lines
75 KiB
Markdown
1139 lines
75 KiB
Markdown
# COMPLETED.md — ReaSampler landed milestones
|
||
|
||
Completed milestone entries removed from `PLAN.md`. Each entry preserves its
|
||
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`.
|
||
|
||
### Comment-reduction pass (tree-wide, twelve parallel tracks)
|
||
|
||
Cut source comment volume tree-wide: 209 files changed, net **−6,493** lines.
|
||
Comment-only lines went from 15,073 to ~8,671, a **~42% cut** — before the
|
||
pass, 37% of all source lines were comment-only. **Zero code drift**, verified
|
||
across all 209 files by comparing comment-stripped hashes; the sole
|
||
intentional exception (Daniel-approved) is two user-facing error strings in
|
||
`src/shell/capture/capture.cpp` that lost internal milestone IDs (`M8`, `M3`,
|
||
`M7+`). Build clean, 61/61 ctest pass. Code review surfaced 2 Major + 8 Minor
|
||
findings — all content cut that should have survived — and all ten were
|
||
remediated and re-gated before merge. Driver: Daniel's instruction — *"Brief
|
||
concise engineering comments. A little why, and maybe context, never WHAT."*
|
||
|
||
### CMake build-system split (ad-hoc, Daniel's request)
|
||
|
||
Split the 1423-line root `CMakeLists.txt` into a 91-line root plus 18 per-directory
|
||
`CMakeLists.txt` files under `src/`, with two shared declaration helpers
|
||
(`reasampler_pure_library`, `reasampler_test`) factored into a new
|
||
`cmake/reasampler_targets.cmake`. The root now keeps only repo-global concerns:
|
||
version/channel single-source-of-truth, `configure_file`, vendor path vars,
|
||
`LICE_SRC`, `enable_testing()`, and the `add_subdirectory` calls. Comments throughout
|
||
were rewritten to the project's comment conventions — phase/wave/ticket IDs removed,
|
||
module semantics already owned by `src/**/CLAUDE.md` deleted, load-bearing build
|
||
facts kept.
|
||
|
||
Also fixed two duplicate-object-code defects surfaced by the split: 18 `core/`
|
||
translation units were previously compiled directly into the `reaper_reasampler`
|
||
module *while also* being linked in as static libraries — those 18 source entries
|
||
were removed from the module's source list and 3 missing link edges
|
||
(`view_tree`, `guid_diff`, `lane_keys`) added so every `core/` TU now enters through
|
||
exactly one static-library link edge. A dead `bridge_marshal` link edge was also
|
||
dropped from `reaper_reasampler`, and two inaccurate comments in
|
||
`src/app/CMakeLists.txt` were corrected.
|
||
|
||
No `.cpp`, `.h`, `tests/`, or `vendor/` file was touched. Behaviour is unchanged and
|
||
was verified mechanically: same 130 targets, same 65 tests all passing,
|
||
`reaper_reasampler.dll` byte-identical at 3,477,504 bytes, both channels
|
||
(stable/beta) building to the same artifact names and locations as before.
|
||
|
||
### Θ-W1-T1 — zone-retirement
|
||
|
||
ReaSampler 9000's zone-mapping system is retired: one loaded capture, one parameter
|
||
set, playing across the full keyboard repitched from root with key-tracking — no zones,
|
||
no per-zone divergence, no keymap of captures. The dedicated zone-editing face and its
|
||
authoring affordances (add/delete zone, per-zone parameter panel, Low/High/Root zone
|
||
legend) are gone; the root note survives as a first-class parameter. `sampler_core`
|
||
split along the note-routing/per-voice-render responsibility seam (no virtual `tick()`
|
||
on the per-voice path), and the Sample face split into chrome/waveform/decks bands with
|
||
a shared band-stack allocator, discharging the wave's two structural deliverables.
|
||
Migration adopts a saved multi-zone instance's first zone; single-zone instances lift
|
||
losslessly.
|
||
|
||
**Deviations from spec:**
|
||
- The key-range open question (**[propose]**) is answered outright rather than left
|
||
open: no key-range concept survives at all — `KeyZone`/`lowNote`/`highNote` are gone
|
||
from the engine, the write format, and the strip. A low/high pair remains re-addable
|
||
later as two ordinary parameters. Θ-W2-T3 consumes this decision.
|
||
- **"Which zone is first" (**[verify]**) confirmed:** `PerformanceMap::zones` was an
|
||
ordered vector and `Keymap::resolve` was first-match-in-order, so index 0 was the
|
||
audible zone. Migration adopts index 0, and that zone's `sampleId` supersedes the
|
||
envelope's stored `selectionId`.
|
||
- The control row (root strip, preview, velocity knob, curve button, Mono|Stereo) moved
|
||
into the chrome band, directly under the title rather than above the deck —
|
||
user-visible and deliberate; it's what makes Θ-W2's band-disjointness real.
|
||
- Loading a capture now clears the three capture-anchored overrides (root, loop span,
|
||
start frame) while keeping the shaping parameters — strictly less destructive than the
|
||
previous whole-zone drop.
|
||
- The embed strip became a read-only readout; it lost its click handler since with no
|
||
zones there is nothing to select.
|
||
- **Migration side-effect:** a previously-zoned instance with implicit channel mode and
|
||
a stereo capture persisted as Mono will reopen as **Stereo**. It converges on the
|
||
documented rule and is reachable only for old blobs, but "sounds identical" was an
|
||
acceptance bar, so it's a real deviation.
|
||
- `sampler_core.{h,cpp}`'s 956-line documented hot-path exception is retired, not
|
||
relocated — the tree now carries no over-ceiling exception at all.
|
||
- `note_entry` was deleted as dead code (its only consumer was the removed zone editor).
|
||
- **Still unverified:** a real pre-change project reopening through REAPER's `setState`
|
||
has not been exercised in the DAW; migration is proven only in the pure domain against
|
||
hand-laid legacy bytes.
|
||
|
||
### Θ-W1-T2 — capture-handoff-bugs
|
||
|
||
Fixed both extension-side capture-handoff defects: drag-out now delivers the capture's
|
||
audio at the drop target every time (previously intermittent, retry-fixable); dropping a
|
||
capture onto an FX container now loads the instrument with the capture, matching the
|
||
FX-button drop path.
|
||
|
||
**Deviations from spec:**
|
||
- Item 5's root cause was three defects, not one: non-atomic COM refcounts racing a drop
|
||
target's background copy; an unverified assumption that REAPER had already called
|
||
`OleInitialize` on the calling thread; and a teardown-before-payload-check ordering bug
|
||
that let an unresolvable payload consume the gesture.
|
||
- Item 6's fix rests on an unconfirmed hypothesis — that a bare `instantiate = -1` left
|
||
placement to REAPER's ambient FX-chain insert point, which a container-focused chain
|
||
window moves. It is now pinned to an explicit top-level position; nobody could confirm
|
||
the mechanism without the DAW.
|
||
- The opportunistic rider was partly taken: `src/ingest.{h,cpp}` re-homed to
|
||
`src/shell/actions/`. `ext_keys.h` was declined (most consumers sit in another track's
|
||
exclusive surface); `resource.h` was declined (it's a build input paired with
|
||
`src/resource.rc` and the SWELL resgen step).
|
||
- **Neither acceptance criterion has actually been met yet.** Item 5's gate is
|
||
explicitly a soak ("a single pass is not a gate") and item 6 needs a live container
|
||
drop; both require REAPER and are outstanding. The code is merged; the acceptance
|
||
gates are not closed.
|
||
|
||
### Θ-W1-T3 — filter-dsp-port
|
||
|
||
Lands the per-voice resonant filter as a standalone pure module
|
||
(`core/instrument/engine/filter/`, five files: `filter_params`, `filter_coeffs`,
|
||
`filter_morph`, `filter_saturate`, `voice_filter`) — concrete `VoiceFilter` type, no
|
||
vtable, no allocation in `process()`. **No call site**; Θ-W2-T1 wires it into the voice
|
||
path.
|
||
|
||
**Deviations from spec — the spec itself changed mid-flight, headline first:**
|
||
- **The Cortex-M4 biquad port was superseded entirely by a TPT/SVF topology, Daniel's
|
||
call.** Measurement found the firmware's high-pass resonance feedback tap vestigial:
|
||
its stated rationale was inverted (the HP numerator approaches 1 as cutoff falls, not
|
||
zero — it's the LP numerator that collapses), it *reduced* HP resonance everywhere it
|
||
ran, and it carried unwanted sample-rate and input-level dependence. It was a Q15
|
||
fixed-point workaround for a ~17-bit cancellation float32 doesn't suffer. Daniel's
|
||
ruling on the resulting level-dependent resonance bloom: *"was a feature on the
|
||
hardware (one knob colorful HP for master FX), wrong choice for this approach."*
|
||
- **Two discrete modes (HP/LP) became a continuous morph, with two selectable morph
|
||
laws** — HP→BP→LP (default) and HP→notch→LP (Oberheim SEM) — chosen at `prepare()`
|
||
via a `MorphLaw` enum on `FilterSettings`. Zero per-sample cost, verified by diffing
|
||
emitted assembly (byte-identical between laws).
|
||
- **A configurable drive stage was added:** an in-loop soft limiter on the band-pass
|
||
integrator state, normalized 0..1, with a radial dial planned for Θ-W2. Drive at 0 is
|
||
bit-exact linear.
|
||
- **`Biquad1PoleLP` was struck (Daniel's call) and never ported.**
|
||
- **Q spans the full 0.1–10 with √2 at the control centre**, replacing the firmware's
|
||
0.707-floored mapping — as originally specified.
|
||
- **No reference sample rate exists anywhere in the module** — rate reaches the DSP
|
||
only via `g = tan(π·fc/sr)`. An interim fix that anchored a feedback tap to a
|
||
`1/48000` constant was superseded by the rewrite.
|
||
- **The rewrite fixed a float32 conditioning defect the biquad carried:** Direct Form I
|
||
measured −27% peak error at 20 Hz / 192 kHz; TPT measures +0.034%.
|
||
- **Still open, deliberately:** drive's maximum depth (4.0, set by measurement — at 64
|
||
the resonant peak inverted below passband) and the absence of makeup gain both await
|
||
an ear pass against the real dial.
|
||
- **The module has no call site** — integration is Θ-W2-T1, which also carries three
|
||
recorded decisions of its own: the envelope-order choice (drive is level-dependent, so
|
||
pre- vs post-envelope placement is sound-defining), the drive dial's calibration, and
|
||
the fact that drive authority varies ~11 dB across the morph sweep.
|
||
|
||
### Θ-W2-T1 — filter-voice-path
|
||
|
||
Wires the pure filter module into ReaSampler 9000's per-voice signal path as a new fixed
|
||
processing point between the pitch envelope and the amp stage, gives it its own
|
||
knob-deck group, and relays the deck row in signal-flow order (pitch → filter → amp).
|
||
Each `Voice` owns its own `VoiceFilter` and a second `AdsrEnvelope` instance — per-voice,
|
||
never shared — and neither adds allocation or virtual dispatch to the per-sample path.
|
||
Parameters — morph position, cutoff, Q, drive, mod amount (bipolar ±100%, targeting
|
||
cutoff), velocity and key-tracking modulation, then AHDSR — live in the one parameter
|
||
set; `FilterParams` stores the filter module's own `FilterSettings` by value rather than
|
||
a parallel copy of the normalized positions. The filter is off by default and bit-exact
|
||
off — a project saved before the change reopens sounding identical, pinned by a
|
||
bit-equality test. `ComponentState`'s params payload moves v8 → v9, appending the filter
|
||
tail; a v8 blob is a strict prefix and lifts to the off/neutral filter default, with
|
||
non-finite filter fields falling back to neutral, pinned by a golden byte-literal
|
||
fixture. Deck composition was extracted into a new pure module
|
||
`core/instrument/ui/deck_groups`, with the pitch → filter → amp row order pinned by
|
||
test; the filter's AHDSR ships as its own `FILTER ENV` group sibling to `FILTER`,
|
||
mirroring the existing `PITCH` / `PITCH ENV` split, and a morph-law toggle (`Band` |
|
||
`Notch`) ships as the FILTER group's row toggle.
|
||
|
||
**Deviations from spec:**
|
||
- **An initial mod-quantizer was added, then rejected and removed.** A
|
||
`kFilterModSteps = 2048` step gate on the coefficient re-solve stair-stepped the
|
||
corner (~5.8 cents per step); Daniel rejected it. Replaced by a cutoff-only re-solve
|
||
(`VoiceFilter::setCutoffNorm`) that re-derives only `g = tan(π·fc/sr)` — Q's parabola,
|
||
the morph's cos/sin, and the folded mix are all cutoff-independent and stay cached
|
||
from `prepare()`; `filter_params` hoists its constant logs. Measured at 48 kHz,
|
||
Release, net of the sweep generator: kernel alone 2.8 ns/frame, full `prepare()` 56.9
|
||
ns, cutoff-only re-solve 15.5 ns — about 1.2% of a core for 16 continuously-swept
|
||
voices. The corner now sweeps continuously rather than stair-stepping.
|
||
- **The editor floor was raised to 840×620**, which is also its new default size, up
|
||
from a 560×460 floor at which the grown deck wrapped to four rows and pushed
|
||
`FILTER ENV` / `AMP ENVELOPE` / `VOICE` / `MASTER` off-screen with no scroll.
|
||
`kEditorMinWidth`/`kEditorMinHeight` now live in `sample_bands`, read by both the
|
||
shell's `checkSizeConstraint` and the opening `ViewRect`. Consequence worth
|
||
recording: a host with a saved editor rect below 840×620 is clamped up on reopen.
|
||
|
||
### Θ-W2-T2 — stereo-waveform-lanes
|
||
|
||
Delivered as specified: two stacked lanes, L above R, in stereo mode; one lane in mono;
|
||
overlays draw once at full stacked height.
|
||
|
||
**Deviations from spec:**
|
||
- **A mono source under stereo mode draws one lane**, not two — lane count keys off
|
||
`stereoMode && sourceChannels >= 2`, not channel mode alone, since a mono capture in
|
||
stereo mode is dual-mono and a second lane would be the redundant duplicate the spec
|
||
forbids. Review confirmed this is the only reading consistent with the decode path.
|
||
- The full-height overlay contract Θ-W3 and Θ-W4 consume is **type-enforced, not
|
||
merely documented**: an `OverlayArea` wrapper type that lane rects cannot satisfy.
|
||
- A single-slot per-channel PCM cache was added to the editor session, invalidating
|
||
alongside the existing PCM cache.
|
||
|
||
### Θ-W2-T3 — toolbar-and-piano-strip
|
||
|
||
Delivered as specified: one toolbar font, no zone-count label, full-width piano strip,
|
||
uniform key widths, note-name tooltips, root displayed and settable.
|
||
|
||
**Deviations from spec:**
|
||
- **The non-uniform key widths were integer quantisation, not aliasing** — the old
|
||
`keyEdgeToX` truncated an exact rational, alternating 6px/7px. Θ-W6's general
|
||
antialiasing audit inherits nothing on key *widths* as a result, though key *edges*
|
||
are still drawn unantialiased.
|
||
- **Uniform integer key widths and gap-free edge-to-edge tiling are mutually
|
||
exclusive** — 75 white keys do not divide an arbitrary width. The residue now lands
|
||
in symmetric end gutters, 37px each side at the shipped 840px default — the maximum
|
||
of a sawtooth with period 75px of window width. Daniel accepted this provisionally,
|
||
pending how it looks in REAPER.
|
||
- The whole control run moved into the toolbar row, not just preview and mono/stereo —
|
||
the full-width strip left the velocity knob and curve button nowhere else to go.
|
||
- Root drag became absolute-tracking rather than pixel-delta, since black keys
|
||
overlaying white give no single pixels-per-semitone rate.
|
||
- **Width uniformity is guaranteed in client pixels only.** Nothing in the instrument
|
||
consumes a DPI scale factor, so host-side scaling is unverified — Θ-W6 already
|
||
carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it.
|
||
- A stale-hover latch was fixed across **all** drag kinds and both drag-termination
|
||
paths, wider than the strip work that surfaced it.
|
||
|
||
### Θ-W3-T1 — live-parameter-delivery
|
||
|
||
Continuous playback controls are now delivered live to sounding voices instead of being
|
||
latched at note-on. New `src/core/instrument/engine/live_params.{h,cpp}` holds a
|
||
seqlock-published `LiveValues` block owned at **processor-instance scope**, above
|
||
`LoadedInstrument`, so `live_` and `draining_` observe the same one (a drain-slot voice
|
||
tracks the knob, which is the desired behavior). `foldLive(const PlayParams&)` is the
|
||
single derivation from the value type; `PlayParams` stays a plain copyable value type.
|
||
|
||
**Daniel's two decisions, both implemented:**
|
||
- **Reload tier = Grouping B.** Continuous knobs live (filter cutoff/Q/morph/drive/mod
|
||
amount/key-track; every stage time and level on all three envelopes). Root note, loop
|
||
span, and start frame still trigger a full reload.
|
||
- **Mid-stage rule = candidate (iv), hold normalized stage position.** φ =
|
||
elapsed/duration held fixed across a duration change, then advancing at
|
||
1/newDuration — expressed over normalized position specifically so Θ-W3-T2's
|
||
per-segment curve exponent composes with it.
|
||
|
||
**Deviations from spec:**
|
||
- **Trigger's %-length and fades are NOT live** — they are baked into `SampleData` at
|
||
build, so reload is the only tier that can deliver them. Consequence: a Trigger-mode
|
||
instance gets zero live amp delivery until Θ-W3-T2 folds the fade pair into the AHD.
|
||
The five non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`,
|
||
`kTrigFadeIn`, `kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`,
|
||
now their single home.
|
||
- Open question 3 resolved as **F2 + seqlock**; open question 4 (sub-block resolution)
|
||
was not built but not foreclosed — the writer interface assumes no UI thread; open
|
||
question 5 verified — a live edit still persists, `commitLive` keeps the
|
||
`setInstrumentParams` write.
|
||
- A filter envelope only advances while its depth is non-zero (the exact-skip at
|
||
`modAmount == 0`), which is what keeps the at-rest path byte-identical.
|
||
|
||
### Θ-W3-T2 — staged-envelope-curves
|
||
|
||
Grows the envelope-overlay editor from an amp-only fixture into the shared graphical
|
||
surface for all three envelopes (amp, pitch, filter): a corner radio switch per deck
|
||
selects which envelope is overlay-active (none by default, exclusive); every sloped
|
||
stage on every envelope (Attack/Decay/Release — Hold and Sustain stay flat) gets an
|
||
editable curve exponent (0.1–10, 1.0 the linear neutral) via a paired inner knob dial
|
||
and a round mid-segment overlay knot, both resolving through the one curve law in
|
||
`src/core/util/curve_law.h`; the pitch envelope becomes AHD (Attack → Hold → Decay,
|
||
Hold a fraction of the time remaining after Attack and Decay, so A+H+D ≤ span holds by
|
||
construction, no clamp); AHDSR envelopes get a right-anchored release, dragged from
|
||
its top node with the bottom-right corner fixed; and the Trigger amp/filter
|
||
fade-in/fade-out pair is retired in favor of a Trigger AHD, consolidating what were
|
||
two staged-shape mechanisms into one — item 8's rule (pitch always AHD; amp and filter
|
||
AHDSR in Gate, AHD in Trigger) governs all three. The Trigger × Preserve end-of-sample
|
||
click is fixed at its root cause: `freezeTail()` stopping the pitch shifter's writer a
|
||
full window before the read head arrives.
|
||
|
||
**Open question resolved — per-mode stage-value state.** Gate and Trigger keep
|
||
SEPARATE stored stage values, on both the amp (`PlaySeconds::adsr` +
|
||
`PlaySeconds::trigAhd`) and the filter (`FilterSeconds::env` + `FilterSeconds::trigEnv`).
|
||
Migration forces it: an old instance carries both an AHDSR and a fade pair, and one
|
||
shared set cannot preserve both modes' prior sound. Cost: ~160 bytes of persisted
|
||
state per instance, 6 additional `DeckParam` ids.
|
||
|
||
**Deviations from spec:**
|
||
- **The migration exponent is FITTED, not neutral — Daniel's explicit ruling,
|
||
resolving a spec contradiction.** PLAN.md stated both "pre-existing instances load
|
||
at exponent 1.0" and "exponents at whatever reproduces the prior fade shape";
|
||
those conflict, and the fix resolves toward the second, since it carries the
|
||
migration guarantee. Attack lifts at **p = 0.6133**, decay at **q = 1.7437**; max
|
||
deviation from the retired equal-power (sin/cos) fade shape drops from 0.2105 to
|
||
0.0875. Every non-migrated curve still lifts to the 1.0 neutral.
|
||
- **Item 4's fix is deliberately WIDER than spec.** The spec scoped the end-of-sample
|
||
click fix to Trigger × Preserve; the landed fix is not mode-scoped, so Gate ×
|
||
Preserve × source-exhaustion also now rings out (~4 ms) where it previously
|
||
hard-cut. A held Gate note whose source runs out with no loop is cut at sustain
|
||
level, landing on the same recycled synthetic tail — scoping the fix to Trigger
|
||
alone would have knowingly left that click.
|
||
- **Migration is lossy under a sample-rate mismatch** — a documented bound, not a
|
||
bug. The retired fades were source frames; the lift divides by the project rate
|
||
while the AHD rebuilds at decode rate, so a rate mismatch shifts migrated stage
|
||
lengths by that ratio. Documented in the v10 version ladder
|
||
(`component_state_io.h`) with a test.
|
||
- **Payload version is v10.** `component_state_io.cpp` was split on the format seam
|
||
into `component_state_io.cpp` + a new `params_payload.{h,cpp}`.
|
||
- **New pure module:** `src/core/util/curve_law.h` — the one per-segment curve law
|
||
(exponent domain, normalized-position→level map, the mid-segment inverse an overlay
|
||
knot drags through, and the knob's norm↔exponent travel with an exact centre
|
||
detent). The neutral exponent is a bit-identity. Measured cost of a non-neutral
|
||
exponent: ~4.7 ns per evaluation, +224 ns/output frame worst case at 16 voices —
|
||
3.1% → 4.1% of one core at 44.1 kHz.
|
||
- **`OverlayEnv` and the overlay-selection state machine live in
|
||
`core/instrument/ui/deck_groups`**, not the shell.
|
||
- **The knot-creation gesture differs from spec.** Spec said dragging a segment
|
||
*adds* a knot; the landed behavior draws the knot unconditionally on every sloped
|
||
non-zero segment and responds to a drag within the grab radius. Daniel confirmed
|
||
this reading stands.
|
||
- **Loop markers moved from `AccentTertiary` to `AccentSecondary`** — they collided
|
||
exactly with the envelope trace (RGB delta 0) in the same overlay rect. Daniel
|
||
ruled. The palette has since settled: `AccentSecondary` is `#38A8A0` (see the
|
||
palette-rework entry below and `src/core/ui/CLAUDE.md`).
|
||
|
||
**Left open by this track, resolved later.** The envelope overlay's contrast against
|
||
the waveform (tertiary purple, measured 1.37:1, below the 3:1 indicator floor) awaited
|
||
Daniel's eye on a build; pinned as a flagged deviation in `tests/test_theme.cpp` at the
|
||
time. Resolved by the ad-hoc palette rework below (`91f71f9`/`a19d645`): the trace moved
|
||
off `AccentTertiary` onto a new `Role::OverlayTrace` (`#816AA6`), clearing the floor at
|
||
3.07:1 — the mathematical ceiling for the pairing. See the palette-rework entry below and
|
||
`src/core/ui/CLAUDE.md`.
|
||
|
||
### Ξ-W1-T1 — tracking-consolidation
|
||
|
||
Consolidates the provenance/usage territory into one system: the retired
|
||
`owned_manifest` gives way to a new `src/core/tracking/` directory holding
|
||
`origin_ledger` (the record family — `OriginRecord`/`OriginKind`, the insertion-ordered
|
||
`OriginLedger`, its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion`
|
||
load classification) and `tracking_authority` (the one decision surface:
|
||
`pruneProtection` and `tiedUsageExists`). Both prune's protected set and the resample's
|
||
replace-vs-add decision are computed from one borrowed `TrackingState`, so the two
|
||
safety-critical consumers cannot drift apart. `isAbsolutePath` was hoisted out to a new
|
||
`src/core/util/relative_path.h`, shared with `bank_model`'s `Sample.relativePath`.
|
||
|
||
**Deviations from spec:**
|
||
- The deferred persisted-instance-identity fix was **not** folded in — open question 5
|
||
resolved as "restate the deferral." `docs/TODO.md` already carries the sharpened
|
||
rationale (the session-epoch candidate and its sibling-drop flaw); not duplicated here.
|
||
- `sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the
|
||
*decisions*, not the codecs.
|
||
- A realtime record interrupted by a project switch strands an untracked WAV in the old
|
||
project's bank folder. Resolved as document-don't-delete (prune is the exclusive
|
||
deletion authority); `docs/TODO.md` carries the entry.
|
||
- `PruneReport` fields were renamed; a malformed ledger is now reported as a distinct
|
||
blocker with its own recovery instructions.
|
||
|
||
### Ξ-W1-T2 — note-program-model
|
||
|
||
Lands the programmed-capture-signal model as a new pure module directory,
|
||
`src/core/instrument/note/` — a fourth peer of `engine/`/`map/`/`ui/` under
|
||
`core/instrument/` — holding `musical_division` (the 1/64–64/1 ladder with
|
||
dotted/triplet multipliers, the 39-entry picker order), `tempo` (validated BPM plus
|
||
every beats↔seconds↔ms conversion), and `note_program` (`Velocity`, the denominated
|
||
`OffsetAmount`, the anchored `StartOffset`/`EndOffset`, the `NoteProgram` record, and
|
||
`resolveNote`).
|
||
|
||
**Deviations / resolutions from spec:**
|
||
- Open question "negative offsets" resolved: both directions are legal and the sign is
|
||
uniform (positive is later in time); only an *inverted* window is refused, reported
|
||
via `ResolvedNote::windowCollapsed`.
|
||
- Open question "denomination seam" confirmed: note length is musical-division-only;
|
||
the ms/beats duality belongs to the offsets alone. An offset stores the denomination
|
||
it was **entered in**, deriving the other view on demand, so a beats offset follows a
|
||
tempo change and a ms offset holds still.
|
||
- Module name/location resolved as `src/core/instrument/note/` — three modules, not
|
||
one, with the layering enforced by the CMake link line.
|
||
- **Beyond spec:** every value type closes its domain at construction behind a single
|
||
normalizing door (`makeDivision`, `offsetOf`, `Tempo::fromBpm`, `Velocity::of`), with
|
||
private value constructors. Consequence: `resolveNote` needs no failure path and
|
||
`ResolvedNote` no validity flag, because every returned field is finite for every
|
||
constructible program and tempo. Junk detection is relocated to the future codec,
|
||
which sees both the bytes it read and the value construction produced. `NoteProgram`
|
||
deliberately carries no MIDI note number — render pitch is deferred to Ξ-W2 as an
|
||
additive field.
|
||
|
||
### Palette rework — accent/secondary darkening + overlay/trace role (ad-hoc, Daniel's request)
|
||
|
||
Two commits (`91f71f9`, `a19d645`) resolve the envelope-overlay contrast wart Θ-W3-T2 left
|
||
open (see above). `accent/secondary` darkened `#84D6D0` → `#38A8A0`; the keyboard strip's
|
||
spectral mid stop decoupled from `accent/secondary` into its own constant, since the
|
||
darkening had inverted the ramp's lo→mid→hi luminance ordering. A new `Role::OverlayTrace`
|
||
(`#816AA6`) was added and the envelope trace + handles repointed onto it: the trace now
|
||
measures **3.07:1** against the waveform — the mathematical ceiling for any single color
|
||
sitting between the primary accent and `bg/base` (9.41:1 apart; `sqrt(9.41) ≈ 3.068`),
|
||
`#816AA6` landing at 99.94% of that optimum. Two below-floor pairs remain deliberately
|
||
accepted — the trace inside the 20%-alpha loop-span fill (2.25:1) and against the
|
||
waveform's `line/hairline` zero-line (1.92:1) — both asserted as pinned ranges in
|
||
`tests/test_theme.cpp` so either direction of drift fails the build.
|
||
|
||
Separately: the bank panel's region title enlarged into WCAG large class via a new
|
||
`Font::RegionTitle` (19px bold); `theme.h`'s large-text thresholds were corrected (a prior
|
||
revision had them ~25% low, letting 15px semibold self-classify as Large); `compositeOver`
|
||
was added to `theme` (the composited-fill arithmetic the loop-span-fill contrast pair
|
||
depends on); and the grabbed envelope handle was repointed off hue onto a size + ring
|
||
treatment, since no two values that clear the overlay-trace ceiling differ enough to carry
|
||
a state by color alone.
|
||
|
||
Full detail — the two-neighbour contrast rule, the WCAG threshold correction, and the
|
||
accepted below-floor pairs — lives in `src/core/ui/CLAUDE.md` and
|
||
`docs/product/visual-design-language.md` §4 Direction B; not duplicated here.
|
||
|
||
### Θ-W4-T1 — gate-loop-sustain
|
||
|
||
Establishes loop points as a usable feature and makes a Gate-mode loop function as the
|
||
sustain — indefinite playback until note-off, with a crossfaded seam. The regression
|
||
half resolved as **present but unreachable, not removed**: nothing in any capture path
|
||
ever wrote `Sample::loop`, so every capture opened with `hasLoop == false`; the ghost
|
||
default parked `loopStart` at frame 0 directly under the start marker, where
|
||
`markerAtPoint`'s first-in-draw-order tie-break made the handle ungrabbable; and no
|
||
crossfade existed at all. Fixed by moving the ghost span to `defaultLoopBounds` (last
|
||
quarter of the sample, both handles clear), making a collapsed span the explicit OFF
|
||
gesture, and adding a parameterized crossfade.
|
||
|
||
New pure module `src/core/instrument/engine/loop/` (`loop_span`, its own CMake target,
|
||
its own `CLAUDE.md`, `loop_span_tests`) holds `resolveLoop`, `defaultLoopBounds`,
|
||
`maxCrossfade`, `crossfadeWeight`, `lerpSource`, `crossfadedSource`. Params payload
|
||
bumped to **v11** (`kParamsLoopVersion`), appended at the tail; slot 12 is reserved for
|
||
Θ-W4-T2.
|
||
|
||
**Open questions resolved:**
|
||
- **Crossfade units and range.** Stored in source FRAMES, not ms — deliberately against
|
||
the plan's ms lean, because `sample_map.h`'s rule keeps source-timeline quantities in
|
||
source frames and the seconds path is documented lossy under a sample-rate mismatch.
|
||
Default 0 frames (a hard seam, which is what makes the migration bar hold by
|
||
construction); range is the derived `[0, min(loopStart, loopEnd − loopStart)]`.
|
||
- **Editing surface.** The waveform markers, plus a new `markerHandleRect` top-strip
|
||
grab tab (top 10px, hit-tested before the full-height marker columns) so markers
|
||
sharing a frame stay independently grabbable — a general fix for the tie-break
|
||
defect, not a crossfade special case.
|
||
- **Crossfade shape.** Settled during implementation, not specified in the source doc:
|
||
linear, not equal-power (correlated taps one loop length apart; no transcendental on
|
||
the per-sample path), with a decorrelated full-mix/stem exception recorded in the
|
||
module's own `CLAUDE.md`.
|
||
|
||
**Deviations from spec / code review:**
|
||
- Code review found one Major: the crossfade normalizer left an avoidable residual
|
||
seam discontinuity, and the module's own `CLAUDE.md` had enshrined that limitation as
|
||
a mathematical impossibility. Remediated — `crossfadeWeight` now normalizes over
|
||
`crossfade − 1` so the last rendered frame lands exactly on the incoming tap, the
|
||
false invariant was corrected, and the seam test now asserts against the material's
|
||
natural one-frame step rather than a proportionality band. Six review minors were
|
||
also fixed.
|
||
- `voice.h` sits at ~650 lines after `lerpSource`/`crossfadedSource` moved out to
|
||
`loop_span.h` — still over the ~600-line ceiling under the standing documented
|
||
hot-path exception.
|
||
|
||
**Left open by this track, deferred to Daniel (not defects):** whether the seam sounds
|
||
smooth on real material, whether the top-strip tab is discoverable, and the LICE
|
||
rendering of the tab and crossfade fill. Also open: whether the crossfade default
|
||
should stay 0 (a smooth seam becomes opt-in).
|
||
|
||
### Θ-W4-T2 — velocity-deck-and-bipolar-curves
|
||
|
||
Gives the three velocity-curve popups (amp, pitch, filter) one home — a new deck group
|
||
labelled VELOCITY — and makes the pitch and filter transfer curves bipolar. No
|
||
velocity-curve button remains in MASTER, PITCH, or Filter. Pitch and filter curves now
|
||
run y range [−1, 1], default flat at 0, so velocity modulation of pitch and filter is off
|
||
until the user draws a curve; amp stays unipolar [0, 1] with its flat-unity default
|
||
unchanged. The domain is modelled as a `CurveDomain { Unipolar, Bipolar }` field on
|
||
`VelocityCurve`, with `curveYMin`/`curveNeutral` deriving from it; `VelocityPoint::amp`
|
||
was renamed to `value`. A velocity→pitch transfer curve is new — it did not previously
|
||
exist. Full scale is `kVelocityPitchRangeSemitones = 24.0`, now the single constant the
|
||
shell's pitch-depth control also consumes; it folds into `baseRatio_` once at note-on, so
|
||
`process()` gains no per-frame work. The preview button's text is replaced by a drawn
|
||
play triangle — `previewGlyph()` returns three vertices from the pure layer, the shell
|
||
passes them to `LICE_FillTriangle`, which was already in the build: no new dependency, no
|
||
asset. Params payload is **v12** (`kParamsVelocityVersion = 12`), appending the
|
||
velocity→pitch curve after Θ-W4-T1's loop block.
|
||
|
||
**Daniel's ruling — the depth knob stays.** The implementation initially *removed*
|
||
`FilterParams::velAmount` and the `kFilterVel` depth knob, arguing a bipolar curve is
|
||
both shape and amount. Daniel rejected that: the knob scalar AND the curve both apply.
|
||
The depth control was restored, and the filter's velocity contribution is
|
||
`velAmount × curve.eval(v)` with the curve bipolar. Consequence: with `velAmount`
|
||
surviving, the pre-v12 migration became a **pure domain re-tag** — a pre-v12 unipolar
|
||
curve's y values already sit inside [−1, +1], so `velAmount` and every knot carry
|
||
forward bit-identically, with no scaling transform and no version branch in the reader.
|
||
The earlier fold-and-rescale approach (and its degree-1-homogeneity argument, which was
|
||
only exact to within double rounding) was removed entirely.
|
||
|
||
**`kFilterVel` also crossed from non-live to live** — a user-visible contract change
|
||
beyond simple restoration. Rationale: it is a depth over a latched value, the same shape
|
||
as `kFilterKeyTrack`, live since Θ-W3; the note latches `curve.eval(velocity)` and the
|
||
depth multiply happens in `applyLive` at block boundaries, gliding through the existing
|
||
cutoff ramp at zero per-sample cost.
|
||
|
||
**Deviations from spec / code review:** Code review ran on two surfaces
|
||
(engine/persistence, UI/editor) and found one Critical plus two actionable Majors and ten
|
||
Minors, all remediated. The Critical: `editedCurve()`'s `kNone` fallback let
|
||
Esc-during-a-curve-node-drag write the pitch or filter curve — bipolar domain and all —
|
||
over the amp gain curve and persist it. Fixed on both routes (the popup close now
|
||
cancels the drag; the mutable accessor refuses `kNone`). It has **no automated
|
||
regression pin** — `src/shell/instrument/` has no test target, and the bug is shell
|
||
state-machine coupling with no pure-layer equivalent.
|
||
|
||
### Θ-W5-T1 — spline-egs
|
||
|
||
Ships a free-drawn alternative to every staged envelope: the pitch, filter, and amp EGs
|
||
can each switch Staged → Spline and have their contour drawn directly on the waveform
|
||
overlay. The one shared monotone-spline implementation
|
||
(`core/instrument/engine/velocity_curve`) gained **hard points** as a per-segment rule —
|
||
a hard point does no curve smoothing on either adjacent segment, so the natural sharp
|
||
angle stands instead of a continuous derivative — and the enhancement flows to every
|
||
consumer, including the existing velocity→amp transfer curve, with no fork.
|
||
|
||
- **Dual state, save-but-inactive.** Both the Staged and Spline state persist
|
||
simultaneously; switching modes never converts or discards the inactive one, so
|
||
Staged↔Spline round-trips losslessly. Params payload reached **v13**; v12 projects
|
||
still load.
|
||
- **Gate unavailable in Spline mode.** A Spline EG's contour always covers the full
|
||
sample length as a pure time function (the Trigger/one-shot playback model), so Gate
|
||
is not selectable while it's active.
|
||
- **Point-editing grammar converged**: left-click adds a point, right-click deletes it,
|
||
control-click toggles hard/smooth — one grammar shared by both spline consumers (the
|
||
EG overlay and the velocity-curve popup), matching the popup's already-shipped
|
||
right-click delete.
|
||
- **Point-count ceiling: 128 — a musical bound, not a performance one.** Segment lookup
|
||
is an indexed binary search (≤7 steps at 128 points); the cap exists so long rhythmic
|
||
phrases (roughly two points per articulation event) aren't limited, not because the
|
||
evaluator is expensive.
|
||
- **Staged controls disabled while Spline is active** — that envelope's segment knobs
|
||
and their inner curve dials render disabled and reject edits; the dormant staged state
|
||
is edited only by switching back to Staged.
|
||
- The overlay's contour is normalized to the full sample length and drawn 1:1 with the
|
||
sample's time axis; a different-length capture rescales the stored contour
|
||
proportionally.
|
||
|
||
A follow-on change in the same track reworked deck cell width: `-1` in `cellIds` changed
|
||
meaning from "a blank cell holding geometry" to **one cell's width, reserved and
|
||
redistributed** — a Trigger face that drops Sustain and Release now gets wider cells
|
||
instead of 144 px of dead slots. Group widths, row packing, deck height, and Gate-mode
|
||
cell widths are unchanged.
|
||
|
||
**Deviations from spec / code review:**
|
||
- A pure `resolveWaveformClaim` predicate (`core/instrument/ui/spline_edit`) now resolves
|
||
competing waveform-band clicks — contour node, crossfade tab, marker column, staged
|
||
envelope node — by **smallest nominal target area among candidates that actually
|
||
contain the click**, replacing resolution by check order.
|
||
- The Gate-unavailable-while-drawn rule was consolidated into
|
||
`enforceGateUnavailableWhileDrawn` (`core/instrument/engine/play_params.h`), now the
|
||
single home of that rule, called by both `resolvePlay` and the editor's
|
||
`applyControl`.
|
||
|
||
### Θ-W6-T1 — legibility-and-antialiasing
|
||
|
||
Made the editor legible, then audited every drawn surface for high-DPI clean rendering
|
||
— sequenced sizing first, audit second, since the audit's disposition list needed a
|
||
surface that had stopped moving.
|
||
|
||
- **Sizing.** Knobs grew 28→40 px (inner curve dial 14→20), the deck cell 48×58→60×74,
|
||
and the label band 12→16 px, now drawn in `Font::Label` rather than `Font::Micro`.
|
||
Group captions and toggle segments deliberately stay `Font::Micro` — bumping them
|
||
would grow the per-group `captionWidth` reserves, and row 1 has only 14 px of
|
||
headroom at the floor width.
|
||
- **Editor default/minimum size 840×620 → 980×680**, because the deck cannot pack
|
||
three rows at the old floor with the wider cells. An existing saved instance's
|
||
window grows on open. The floor is validated by a derived test rather than
|
||
literals.
|
||
- **All 14 time-constant labels now read in ms**; internal representation untouched
|
||
(`formatEnvTimeMs` is display-only). `holdFraction` knobs and `Len %` stay `%` —
|
||
they are fractions, not times. The bank panel's clip-length readout is a duration,
|
||
not a parameter time constant, and stayed out of scope.
|
||
- **Double-click reset, per ring.** Outer ring resets the value, inner dial resets
|
||
the exponent to 1.0, independently. The window class gained `CS_DBLCLKS`;
|
||
`WM_RBUTTONDBLCLK` was added as its peer so the spline right-click delete survives,
|
||
and both DBLCLK handlers fall through to the ordinary down handler. The chrome's
|
||
preview-velocity knob answers reset too, resolving against the drawn circle via a
|
||
shared `inKnobFace` rule now used by both the deck and the chrome.
|
||
- **Antialiasing pass.** Fixed: knob track/value arcs (widened to 3 px stacked-radius
|
||
AA arcs), knob needle (`LICE_ThickFLine`), inner dial arc and needle, staged
|
||
envelope slopes, spline contour, velocity-popup trace, waveform outline, preview
|
||
triangle. Already clean: node handles, curve knots, knob discs, buttons, piano
|
||
keys, loop markers, borders, gradients, text. The full disposition table is a
|
||
standing artifact in `docs/product/visual-design-language.md` §8.
|
||
- Three LICE facts the audit established: `LICE_Line` takes integer endpoints so
|
||
`aa=true` still quantizes; `LICE_FillTriangle` has no `aa` parameter at all; LICE
|
||
has no thick-arc call, so a wider ring is stacked 1 px arcs.
|
||
- **Measured cost:** the new AA waveform stroke adds ~0.41 ms per full-grid panel
|
||
repaint (0.070 → 0.48 ms over a 24-card × 2-band × 136-column grid), ~2.5% of a
|
||
60 Hz frame. Recorded in the §8 table and annotated as a one-off scratchpad
|
||
measurement, not a standing regression guard.
|
||
- **The piano-key open question is answered: not aliasing.** Every key is an
|
||
axis-aligned integer-width `LICE_FillRect`, so there was no sloped edge for
|
||
aliasing to act on; the defect was integer-division residue in the tiling, and
|
||
W2-T3's fix (remainder moved into symmetric end margins) is arithmetic. Above
|
||
client-pixel scaling it is unverified — nothing implements
|
||
`IPlugViewContentScaleSupport`.
|
||
|
||
**Two structural changes forced by review.** The waveform column's vertical
|
||
arithmetic moved into a pure, unit-tested `waveformColumnSpan` in
|
||
`core/ui/component_geometry` — the first pass had silently broken symmetry about
|
||
the midline in the shared `draw_kit` primitive that also feeds the docked bank
|
||
panel and browse thumbnails. And `PlaySeconds` plus its `AdsrSeconds`/
|
||
`AhdSeconds`/`PitchEnvSeconds`/`FilterSeconds` companions hoisted out of
|
||
`sample_map.h` into a header-only `play_seconds` INTERFACE target, so the new
|
||
`core/instrument/ui/deck_values` module stops transitively linking the bank model
|
||
and WAV codec. `deck_values` itself is an extraction of `controlValue`/
|
||
`applyControl`/`resetDeckParam`/the ms formatter out of the editor shell, making
|
||
reset semantics unit-testable; `editor_controls.cpp` dropped 469→293 lines.
|
||
|
||
All visual outcomes remain **pending Daniel's by-eye sign-off on `dev`** — sizes,
|
||
arc weight, and whether the waveform stroke improves or thickens the docked panel.
|
||
Not recorded as accepted.
|
||
|
||
### Θ-W7-T1 — arc-and-spline-aa
|
||
|
||
Two defects Daniel found by eye once Θ-W6-T1's antialiasing pass shipped — diagnosing
|
||
both corrected the initial reading of each.
|
||
|
||
- **Arcs never reached opacity.** `LICE_Arc` rasterizes a whole circle clipped per 90°
|
||
chunk and splits ink across two pixels by the fractional part of the radius;
|
||
`rOuter = radius - 0.5f` is half-integer, so no pixel in the ring was ever opaque —
|
||
measured peak alpha 138/255. The three stacked radii also did not tile: spacing
|
||
dilates from 1.0 px to 1.41 px at 45°, leaving partial-coverage holes. It read as
|
||
fuzz, but it was a stroke that never fully inked.
|
||
- **Splines were fully aliased, not gapped.** The apparent dotting was not missing
|
||
ink: `LICE_ThickFLine` steps the major axis and structurally cannot gap. The paint
|
||
loop passed **integer** `cx`/`cy`, so LICE had no sub-pixel position to interpolate
|
||
— every pixel was full or empty with no AA fringe, and integer `cy` quantized the
|
||
slope into an alternating 1/2 px staircase that reads as beading at 100%.
|
||
- **The cheap fix was rejected.** Float endpoints plus `LICE_ThickFLine` fixes
|
||
opacity and the staircase, but `ThickFLine` lays width along the *minor* axis, so
|
||
perpendicular weight is `wid·cosθ` — a measured 42% ripple dipping at every 45°
|
||
diagonal.
|
||
- **What landed:** one pure analytic thick-stroke rasterizer. Coverage is
|
||
distance-to-polyline, accumulated with `max()` into a scratch buffer and blended
|
||
**once** — the single blend is what structurally prevents the compositing fringe
|
||
build-up behind the first defect. An arc is just a polyline, so one code path
|
||
replaces the stacked arcs, both spline traces, and the two needles. Pure coverage
|
||
math in a new `core/ui/stroke_aa`; the blend loop in a new
|
||
`shell/instrument/editor_stroke`. `shell/panel/draw_kit` was deliberately **not**
|
||
touched, keeping the docked bank panel and browse cards entirely out of the blast
|
||
radius.
|
||
- **Measured, before → after:** arc peak alpha 138/255 → 255/255; arc perpendicular
|
||
weight 1.62–3.24 px (67% ripple) → 2.95–3.11 px (5%); spline weight 1.41–2.00 px
|
||
(29%) → 1.95–2.01 px (3%). Cost: **+0.09 ms per full editor repaint** (30 arcs
|
||
0.113 → 0.169 ms; 500 px contour 0.013 → 0.047 ms), a knowing regression on an
|
||
interaction-driven surface, measured in Release against real LICE in an
|
||
uncommitted harness.
|
||
- **`velocity_curve` gained `subpixelFromPoint`** — sub-pixel y was unavoidable since
|
||
integer `cy` was the root cause. The existing integer map now *rounds* the new
|
||
float map rather than forking a second formula, so hit-testing is unchanged.
|
||
- **Daniel then ruled that every sub-2 px stroker width be enlarged**, because the
|
||
stroker can only guarantee an opaque core at width >= 2 px (an opaque pixel needs
|
||
`d <= halfWidth − 0.5`, and the worst-case pixel-centre-to-centreline distance is
|
||
0.5). The knob track arc, the inner-dial needle, and the deck's mini velocity trace
|
||
all moved 1.0 → 2.0 px. A test pinning the sub-opaque behaviour at 1 px was kept as
|
||
a guard against reintroduction.
|
||
- **The audit's method was the root failure, not its output.**
|
||
`docs/product/visual-design-language.md` §8 had claimed stacked 1 px `LICE_Arc`
|
||
calls "keep every ring antialiased" — false. The Θ-W6 audit verified *which
|
||
primitive was called* rather than *what it rasterized*, which is how both surfaces
|
||
were signed off clean while never producing an opaque pixel. That sentence is
|
||
deleted, the rows are re-dispositioned with measurements, and the methodological
|
||
lesson is recorded in §8 as a standing blockquote.
|
||
|
||
All visual outcomes remain **pending Daniel's by-eye sign-off on `dev`** — nothing was
|
||
verified in a live REAPER window; all measurement was against an offscreen bitmap in a
|
||
standalone harness. Not recorded as accepted.
|
||
|
||
### Ξ-W2-T1 — resample-bake-chain
|
||
|
||
The one-click in-sampler resample: dial → bake → dial-again, run without leaving the
|
||
sampler. A single click renders the dialed sound through the instrument's own voice
|
||
path, banks the result, re-points the instance at it, and hands the parameter set back
|
||
neutral — with the recapture's superseded predecessor never deleted, only retired to
|
||
prune's reclaim pool.
|
||
|
||
**The architecture decision was this track's first deliverable, and Daniel ratified
|
||
both halves of it.** Decision 1 (how the click crosses to the extension) is **(1b)**:
|
||
the editor invokes the extension's bake action directly over the VST-host bridge —
|
||
`NamedCommandLookup` on `"_" + channelCommandId(...)`, then `Main_OnCommandEx` — so
|
||
there is no request poller, no nonce, and no cross-process handshake. This dissolves
|
||
the S13 DEGRADED verdict rather than re-litigating it. Decision 2 (what renders the
|
||
audio) is **(2c)**: the instrument renders in-process and the extension banks the
|
||
file — taken **over the plan's leaning toward (2a)** on an engine-version-skew
|
||
argument: under (2a) the extension's own copy of the voice engine would render audio
|
||
the user heard through the VST3's separately-installed copy, and the format ladders
|
||
do not catch a behavioral divergence between the two. (2c) also leaves the
|
||
extension's link graph untouched, preserving `component_state_io`'s split-out purpose
|
||
of keeping engine object code out of the extension.
|
||
|
||
**What shipped:**
|
||
- New pure modules: `src/core/instrument/bake/` — a fifth peer of `engine/`/`map/`/
|
||
`note/`/`ui/` under `core/instrument/`, holding `bake_plan`, `bake_render`, and
|
||
`bake_reset` — plus `core/model/resample_name` and `core/wire/bake_wire`.
|
||
- New shells: `shell/instrument/instrument_bake` (the instrument's half: render,
|
||
stage the WAV outside the bank, publish one `rsbake_<guid>` request, invoke the
|
||
extension's action synchronously, read the outcome back, adopt + reset) and
|
||
`shell/capture/bake_land` (the extension's half: scans every open project tab for
|
||
pending requests, lands the ones belonging to the loaded project, refuses the rest).
|
||
- One new `ActionTableRow`, `RESAMPLE_BAKE`, registered through `main.cpp`'s existing
|
||
data-driven table.
|
||
- The instrument's guarded ext-state write surface grew from one prefix (`rsusage_`)
|
||
to two (`+ rsbake_`); the read-only-**bank** invariant holds because a bake
|
||
request key is not bank state, and the structural prefix guard still refuses
|
||
`banks`/`view`/`tail`/`assign`.
|
||
|
||
**Reset-scope classifications made at review, against Daniel's ratified rule** — these
|
||
parameters were absent from both ratified lists, so the classification itself is this
|
||
track's durable output:
|
||
- **Play mode → RESET, to Trigger.** The bake's product is a finished one-shot
|
||
carrying its own attack, span, and release; Gate would re-gate it and re-truncate
|
||
the printed tail on every iteration, breaking "iteration composes indefinitely." The
|
||
user-visible consequence: after a bake the instance is in Trigger, and a sustained
|
||
instrument needs Gate re-dialed by hand.
|
||
- **Start point → RESET.**
|
||
- **Channel mode and preview velocity → SURVIVE.**
|
||
- `resetAfterBake` defaults everything and copies back only the survivors, so a
|
||
parameter added later resets by default.
|
||
|
||
**Two behaviors worth recording because they are user-visible:**
|
||
- A bake fired from an instance in a **background project tab refuses** with
|
||
`BakeStatus::WrongProject` rather than risking a write into the wrong project's
|
||
bank — landing requires three-way agreement between the request's tab, the
|
||
session's loaded project, and the focused tab.
|
||
- A crash-stranded bake request is **cleared, not landed**, past a 30-second
|
||
staleness window (`kMaxRequestAgeSeconds`).
|
||
- Extension presence: the resample affordance reads **cleanly unavailable, not
|
||
silently lossy**, when the extension is not loaded — `bakeAvailable` gates the
|
||
editor's paint state and `runBake` refuses up front with "resample needs the
|
||
ReaSampler extension loaded" if asked anyway.
|
||
|
||
**Not demonstrated.** Nothing was verified in a live REAPER session. The
|
||
audible-and-faithful, iteration-composes, save/reload, and arrange-untouched
|
||
acceptance criteria are structural in the code and untested in a DAW. Three facts
|
||
remain DAW-unverifiable and are handled defensively rather than asserted:
|
||
`NamedCommandLookup`'s return on an absent command, `Main_OnCommandEx`'s `flag`
|
||
semantics, and whether a `WM_TIMER`-issued invoke is honoured. Daniel's manual
|
||
verification is still owed.
|
||
|
||
**Naming and lineage — proposed, not ratified, and still open jointly with
|
||
Ξ-W1-T1's lineage-record question.** This track's proposal: `Kick` → `Kick r2` →
|
||
`Kick r3`, incrementing rather than stacking; a replace keeps the source's name;
|
||
machine-readable lineage rides `OriginRecord::parentSampleId`, written at birth
|
||
(landed by Ξ-W1-T1). The proposal is implemented (`core/model/resample_name`) but not
|
||
itself a ratified decision.
|
||
|
||
**Deferred, not done — logged to `docs/TODO.md`:** `Sample::sourceMode` has no value
|
||
meaning "produced by the instrument" (appending one is a forward-incompatible
|
||
bank-format change under the current deserializer, which fails the whole bank blob on
|
||
an out-of-range value — it wants its own decision); and `instrument_bake` copies the
|
||
interleaved render buffer into a `std::vector<double>` for the WAV build, roughly
|
||
doubling peak memory for a large bake.
|
||
|
||
### Ξ-W3-T1 — capture-signal-popup (spec abandoned by ruling; shipped as derived bake window)
|
||
|
||
Phase Ξ's final track, and Phase Ξ is now complete. What `docs/PLAN.md` specified was a
|
||
popup menu letting the user hand-program the capture signal: note length as a
|
||
musical-division picker (1/64 to 64/1, dotted and triplet), start/end offsets editable in
|
||
both ms and beats, velocity, and a preview trigger auditioning the programmed note, under
|
||
the acceptance criterion "preview and bake cannot diverge."
|
||
|
||
**What shipped instead, and why — the spec and the landed code diverge substantially and
|
||
deliberately, by Daniel's ruling, not by shortfall.** The popup was built (~1500 lines)
|
||
and then abandoned unmerged. Daniel, verbatim: *"I didn't realize you had already derived
|
||
a usable window. The manual stuff for baking a specific midi length was just an idea, if
|
||
we have a smarter, fewer-clicks way of doing it, that is ideal. I just don't want to lose
|
||
anything when we bake. We can abandon the whole parameterized bake window if we can safely
|
||
derive the window in gate and trigger modes."* An audit then established, with executable
|
||
tests (`tests/test_bake_window.cpp`), that the window derives losslessly everywhere except
|
||
one irreducible case. What actually shipped, in `src/core/instrument/bake/`:
|
||
|
||
- **The bake window derives itself.** Trigger derives from the play span; Gate *without*
|
||
an active sustain loop derives from source exhaustion + release; Gate *with* an active
|
||
loop takes one user value, because a loop sounds for as long as it is held and no
|
||
derivation can supply a duration.
|
||
- **One control: "Hold,"** a musical-division picker in the chrome row, visible and
|
||
settable only when Gate + an active sustain loop. `bakeWindowNeedsHold` is the predicate
|
||
and it reads the ENGINE's loop fold (`resolveLoop`) rather than the loop fields.
|
||
- **Velocity comes from the instance's persisted preview velocity**, not a hard-coded 100
|
||
— three velocity curves are live, so the velocity is a property of the sound being
|
||
printed.
|
||
- **No preview trigger, and no popup at all.** The chrome-row play button stays a pure
|
||
MIDI trigger; bake parameters are their own thing. Daniel's ruling: *"play button is
|
||
pure MIDI trigger, Bake parameters are their own thing."* So the plan's acceptance
|
||
criterion 2 ("preview and bake cannot diverge") and its preview-trigger behavior bullet
|
||
are **retired by ruling.**
|
||
- **Three truncation bugs that pre-existed on `dev` were found and fixed:** a drawn EG
|
||
plus a stale stored `%`-length lost up to the whole take; the Preserve pitch engine's
|
||
window closed on the exact frame the terminal declick ramp began, ending files on a
|
||
full-scale hard cut; and the Gate hold length quantized onto a musical ladder that
|
||
**saturated at 384 beats**, cutting any source past it mid-sound (at 120 BPM, anything
|
||
from 192 s up — a full-mix bounce).
|
||
- **An invariant was deliberately amended:** `note/CLAUDE.md`'s "note length stays
|
||
musical-division-only" is superseded — a note length now carries EITHER an exact
|
||
duration (every derived path) or a musical division (the Hold picker only).
|
||
Quantizing a derived length is what caused the saturation truncation.
|
||
- Two undefined-behaviour paths closed as fallout: a NaN `keyTrack` from a corrupt payload
|
||
reached a narrowing cast on the per-sample audio path, and a misaligned payload tail
|
||
could fabricate a value rather than degrade to absent.
|
||
- Payload rung **v14** consumed (the Hold division).
|
||
|
||
**Not verified in a live REAPER session** — worth carrying forward as owed: the "Bake
|
||
Hold" label fitting its 56 px cell, the Hold knob's duration-ordered travel, and the
|
||
control's appearance/disappearance on the 500 ms sync tick.
|
||
|
||
**Deferred, not done — logged to `docs/TODO.md`:** the loop intrinsic is folded twice
|
||
(the editor's `pickedMarkers` resolves it from the live bank blob first, the processor's
|
||
`reloadInstrument` resolves it from the instance ref via `resolveCapture`), so the two can
|
||
disagree whenever a bank blob's loop for a capture differs from the copy in the instance's
|
||
own refs table. Pre-existing — `bakeWindowNeedsHold` is only a new *consumer* of
|
||
`pickedMarkers`, not the origin of the divergence.
|
||
|
||
### Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono
|
||
|
||
Seven tracks across three waves, code-complete, reviewed, remediated, and integrated on
|
||
this branch: 89/89 tests passing, a clean build. Phase Ψ came from a direct list of
|
||
seven defects and refinements (Daniel, 2026-08-01) rather than a backing product doc —
|
||
see `docs/PLAN.md`'s Phase Ψ section for the Ψ.1–Ψ.7 provenance list this phase traces
|
||
back to.
|
||
|
||
**Ψ-W1-T1 — `capture-range-exactness`.** A ranged item capture now renders the
|
||
requested window instead of the whole item, by re-sourcing through the selected-tracks
|
||
render when the item extent does not already print the window. **Deviation worth
|
||
recording:** the spec named two candidate architectures; the engineer shipped a
|
||
*conditional* form of candidate (a) — the full-extent case runs literally unchanged
|
||
code, which makes the byte-identity regression floor structural rather than hoped-for,
|
||
and makes the fix cheap to revert if the underlying inference proves wrong. Also added:
|
||
a transient isolation guard cutting `B_MAINSEND` on direct folder children and muting
|
||
receives so an item capture stays true to item scope, and a post-render frame-count
|
||
gate (±1 tolerance, tail-None only) that refuses a widened render and retains it outside
|
||
the bank for diagnosis rather than deleting it. New
|
||
modules `core/capture/render_window`, `core/capture/track_topology`,
|
||
`shell/capture/render_selection`, `shell/capture/render_isolation`.
|
||
|
||
**Ψ-W1-T2 — `mode-switch-discipline`.** Per-mode SOLO surfaces: solo cached, cleared,
|
||
and replayed across a Design/Arrange switch, with the switch itself visibly refused
|
||
while the transport runs. New `core/view/solo_cache`, `shell/view/view_solo`. Also
|
||
closed a pre-existing bug where a footer mode-segment click never persisted view state.
|
||
Required amending a thrice-stated never-touch-solo invariant (`src/shell/view/CLAUDE.md`,
|
||
`src/core/view/CLAUDE.md`, `docs/product/design-view.md`) to the snapshot sense of
|
||
non-destructive: solo is cached per mode on a real switch and restored verbatim, not
|
||
left untouched absolutely the way `B_MUTE` and the master track are.
|
||
|
||
**Ψ-W1-T3 — `media-explorer-section`.** The Media Explorer import action is published
|
||
into REAPER's Media Explorer action section (32063) via `custom_action` +
|
||
`hookcommand2`, while remaining in Main so existing keybindings survive. A second
|
||
FOREVER-STABLE id was minted — `INGEST_IMPORT_MEDIA_EXPLORER_MX` — permanent, per
|
||
channel. The root `CLAUDE.md` REAPER extension contract gained the second,
|
||
non-main registration mechanism alongside the original four-step main-section pattern.
|
||
|
||
**Ψ-W1-T4 — `drop-target-resolution`.** The drag-out gesture became a per-move,
|
||
stateless law: target class resolves from what is under the cursor on every move,
|
||
transitions reversible, OS hand-off reserved for leaving REAPER. The whole TCP/MCP is
|
||
now the instrument-drop hotspot; a single-card arrange drop lands a timeline item at
|
||
the pointer's track and time; every surface has a defined outcome and cue, no silent
|
||
no-op release anywhere. New `shell/actions/arrange_drop_win`.
|
||
|
||
**Ψ-W2-T1 — `capture-naming`.** Captures are named after their source track plus a
|
||
discriminator (`<Track> [+N] [#ordinal] MM-DD HHMM`) at every interactive mint site,
|
||
with the name shown on the panel card over a scrim clearing the 4.5:1 contrast floor.
|
||
New `core/capture/capture_name`. Recapture, ingest, and the bake deliberately keep
|
||
their own naming.
|
||
|
||
**Ψ-W2-T2 — `mono-collapse`.** A capture whose channels are bit-identical collapses to
|
||
one lossless mono channel, written via temp file plus atomic rename, with the index's
|
||
channel count now *measured* off the landed file rather than echoed from the request.
|
||
Required amending root `CLAUDE.md`'s channel-count-preserved precision invariant — the
|
||
current wording ("no lossy channel fold... one permitted collapse is lossless") is the
|
||
landed form.
|
||
|
||
**Ψ-W3-T1 — `track-scope-range` — a wave that did not exist when the phase was
|
||
scoped, and consolidates none of the original seven.** Opened after Ψ-W2's review
|
||
surfaced that the track scope carried the same multi-track stem-collapse hole
|
||
Ψ-W1-T1 had just closed for item scope. Now any multi-track selected-tracks render
|
||
refuses, both scopes, keyed on the render *source* rather than the capture scope.
|
||
Realtime deliberately diverges — it sums correctly and was left untouched.
|
||
|
||
**None of the seven is DAW-verified.** All are code-complete and unit-tested; none
|
||
has been confirmed in a running REAPER. Several rest on a **shared unverified
|
||
inference** about how REAPER's selected-tracks render source interacts with custom
|
||
time bounds — and Ψ-W3's refusal now rests on it too, meaning if the inference is
|
||
wrong that refusal costs a working capture. Each track's DAW-verification obligation
|
||
is recorded in `docs/PLAN.md`'s Phase Ψ section; `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 50–200 % 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.
|