91 KiB
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.
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.
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/highNoteare 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::zoneswas an ordered vector andKeymap::resolvewas first-match-in-order, so index 0 was the audible zone. Migration adopts index 0, and that zone'ssampleIdsupersedes the envelope's storedselectionId. - 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_entrywas deleted as dead code (its only consumer was the removed zone editor).- Still unverified: a real pre-change project reopening through REAPER's
setStatehas 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
OleInitializeon 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 = -1left 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 tosrc/shell/actions/.ext_keys.hwas declined (most consumers sit in another track's exclusive surface);resource.hwas declined (it's a build input paired withsrc/resource.rcand 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 aMorphLawenum onFilterSettings. 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.
Biquad1PoleLPwas 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 a1/48000constant 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 = 2048step 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 onlyg = tan(π·fc/sr)— Q's parabola, the morph's cos/sin, and the folded mix are all cutoff-independent and stay cached fromprepare();filter_paramshoists its constant logs. Measured at 48 kHz, Release, net of the sweep generator: kernel alone 2.8 ns/frame, fullprepare()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/MASTERoff-screen with no scroll.kEditorMinWidth/kEditorMinHeightnow live insample_bands, read by both the shell'scheckSizeConstraintand the openingViewRect. 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
OverlayAreawrapper 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
keyEdgeToXtruncated 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
SampleDataat 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 insrc/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,
commitLivekeeps thesetInstrumentParamswrite. - 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.cppwas split on the format seam intocomponent_state_io.cpp+ a newparams_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. OverlayEnvand the overlay-selection state machine live incore/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
AccentTertiarytoAccentSecondary— they collided exactly with the envelope trace (RGB delta 0) in the same overlay rect. Daniel ruled. The palette has since settled:AccentSecondaryis#38A8A0(see the palette-rework entry below andsrc/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.mdalready carries the sharpened rationale (the session-epoch candidate and its sibling-drop flaw); not duplicated here. sample_usagedeliberately stays incore/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.mdcarries the entry. PruneReportfields 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:resolveNoteneeds no failure path andResolvedNoteno 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.NoteProgramdeliberately 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
markerHandleRecttop-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.mdhad enshrined that limitation as a mathematical impossibility. Remediated —crossfadeWeightnow normalizes overcrossfade − 1so 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.hsits at ~650 lines afterlerpSource/crossfadedSourcemoved out toloop_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
resolveWaveformClaimpredicate (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 bothresolvePlayand the editor'sapplyControl.
Θ-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::Labelrather thanFont::Micro. Group captions and toggle segments deliberately stayFont::Micro— bumping them would grow the per-groupcaptionWidthreserves, 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
(
formatEnvTimeMsis display-only).holdFractionknobs andLen %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_RBUTTONDBLCLKwas 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 sharedinKnobFacerule 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 indocs/product/visual-design-language.md§8. - Three LICE facts the audit established:
LICE_Linetakes integer endpoints soaa=truestill quantizes;LICE_FillTrianglehas noaaparameter 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 implementsIPlugViewContentScaleSupport.
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_Arcrasterizes a whole circle clipped per 90° chunk and splits ink across two pixels by the fractional part of the radius;rOuter = radius - 0.5fis 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_ThickFLinesteps the major axis and structurally cannot gap. The paint loop passed integercx/cy, so LICE had no sub-pixel position to interpolate — every pixel was full or empty with no AA fringe, and integercyquantized the slope into an alternating 1/2 px staircase that reads as beading at 100%. - The cheap fix was rejected. Float endpoints plus
LICE_ThickFLinefixes opacity and the staircase, butThickFLinelays width along the minor axis, so perpendicular weight iswid·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 newcore/ui/stroke_aa; the blend loop in a newshell/instrument/editor_stroke.shell/panel/draw_kitwas 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_curvegainedsubpixelFromPoint— sub-pixel y was unavoidable since integercywas 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 pxLICE_Arccalls "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 ofengine//map//note//ui/undercore/instrument/, holdingbake_plan,bake_render, andbake_reset— pluscore/model/resample_nameandcore/wire/bake_wire. - New shells:
shell/instrument/instrument_bake(the instrument's half: render, stage the WAV outside the bank, publish onersbake_<guid>request, invoke the extension's action synchronously, read the outcome back, adopt + reset) andshell/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 throughmain.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 refusesbanks/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.
resetAfterBakedefaults 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::WrongProjectrather 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 —
bakeAvailablegates the editor's paint state andrunBakerefuses 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.
bakeWindowNeedsHoldis 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
devwere 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
keyTrackfrom 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.
Γ-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.