224 Commits

Author SHA1 Message Date
daniel 3eae942625 1.7.5 2026-08-06 09:50:21 -04:00
daniel 03f7fb5081 Merge branch 'omega-w3-t3-undo-collapse' into dev 2026-08-06 09:49:22 -04:00
daniel ab14ae769f Guard poll() against a mode-apply mid-flight; two doc corrections
Extends the OnTimer guard to skip session.poll() itself while modeApplyInProgress(), preventing an undo/redo or project-switch reload from replacing the view model under an in-flight applyMode.
2026-08-06 05:23:58 -04:00
daniel 2269fd80b6 Close the collapse's two dropped guarantees: latch applyMode against re-entrancy, pin its project once; correct three overclaiming doc lines 2026-08-06 05:07:31 -04:00
daniel 8c06383fe7 Collapse a Design View mode switch to ONE undo point: FX writes run inline in applyMode's block, deferred park queue deleted 2026-08-06 04:32:26 -04:00
daniel 8a7056d19d Fix undo-label misnomer, tighten drain-nesting/abort-path docs, mark inferred undo behavior as unverified
Renamed the drain's undo point to "Design View FX state" (it restores as often as it parks). Extended the nesting audit to cover a block opening inside the drain's own block. Marked post-fix undo behavior as inference pending DAW checks.
2026-08-05 20:54:31 -04:00
daniel 13c824c63d Collapse the FX-park drain's writes into one undo point instead of one per TrackFX_SetOffline 2026-08-05 20:40:45 -04:00
daniel bd86ede5b3 Merge FX-park snapshot guard: refuse to park when the pre-park truth is gone
A snapshot is never taken from a chain a park has touched, on either
half -- flags or per-FX offline. Stranded tracks are refused, named
once in the console, and left recoverable by hand.
2026-08-05 20:17:00 -04:00
daniel 4b306dd436 Name refused tracks before Undo_EndBlock2, not after; tighten the route-list doc; pin the refusal memo's gate
Fixes D (handle lifetime), C (ASCII dashes), G (comment compression), I (shouldReport extracted + tested); CLAUDE.md route list corrected per A/B/H. E pushed back -- cross-module GUID plumbing is architectural, not a targeted fix.
2026-08-05 17:34:49 -04:00
daniel 5376ab085c Extend the park trust test to the FX chain, name the refused tracks, and stop reprinting an unchanged refusal 2026-08-05 17:10:19 -04:00
daniel 67524206fb 2nd Pass Render 2026-08-05 16:21:17 -04:00
daniel 203961f41c Refuse to park a track whose pre-park snapshot is gone, rather than re-snapshotting the already-parked chain as the user's state 2026-08-03 18:30:40 -04:00
daniel 2f96dd3da5 Revert Ω-W2-T4: FX-park state stranded across save/reopen
Backs out the deferred park drain split. Forced synchronous FX-park
drains return, and with them the mode-switch hitch, until the
re-snapshot-after-discard fix lands.
2026-08-03 18:05:04 -04:00
daniel 098727e464 Merge panel polish: crosshair arrange-drop cue, Re-capture and Insert Conform to the overflow menu 2026-08-03 17:50:09 -04:00
daniel 0e6a0d3618 fix: use crosshair cursor for arrange-drop cue instead of I-beam
The I-beam read as a text-editing cursor rather than a timeline placement point.
2026-08-03 17:49:18 -04:00
daniel 8487833342 Revert "Merge Ω-W2-T4: split the deferred FX-park drain by kind"
This reverts commit 70bd29dc89, reversing
changes made to 84afd1b545.
2026-08-03 17:46:17 -04:00
daniel d22446ef7d Move Re-Capture and Insert Conform from the panel top bar to the overflow menu; re-pin action_bar width test to the new 4-button top bar. 2026-08-03 17:44:00 -04:00
daniel 4b19556513 Merge Insert as FX: load ReaSampler 9000 onto the selected track from the panel
# Conflicts:
#	src/shell/actions/CLAUDE.md
2026-08-03 16:46:40 -04:00
daniel a5febbd6aa Merge drag-out hand-off fix: keep the drag internal while the pointer is over REAPER 2026-08-03 16:45:14 -04:00
daniel 51c7505dc0 Fix Insert-as-FX review findings: id rename, selection-count drift, refusal tooltip, success msg, master-only, reserve test
Renames the permanent action id out of the placement family, makes the button's
painted state and pressed outcome share one selection count, surfaces
panel-known refusals in the tooltip, reports the target track on success,
distinguishes a master-only selection, and pins the overflow-reserve test to the
real bar spec.
2026-08-03 16:43:10 -04:00
daniel b71a05fbef Close the bridged-process hole in the drag-out hand-off gate
OsHandoff now needs the window-ownership proof AND a hit-test that named
nothing, so a bridged plugin's UI can't read as off-REAPER.
2026-08-03 16:42:59 -04:00
daniel aa87d81f1f Merge Ω-W2-T5: bound the waveform zero-crossing snap to a pixel radius
Single-cycle loop marks stop teleporting to the one interior crossing. Ctrl on a
marker drag defeats the snap, and no longer loses the grab to a coincident node.
2026-08-03 16:09:27 -04:00
daniel 70bd29dc89 Merge Ω-W2-T4: split the deferred FX-park drain by kind
Restores stay forced before the view model serializes; parks go one FX per idle
tick behind a 1s coalescing debounce, so a rapid A-B-A flip costs no plugin work.
2026-08-03 16:09:20 -04:00
daniel 84afd1b545 Merge Ω-W2-T2: throttle new-content enumeration off the 30/s timer tick
Extracts elapsedAtLeast to core/util with the GetTickCount wraparound asserted.
2026-08-03 16:09:12 -04:00
daniel f91054276b fix(instrument): stop Ctrl-before-click stealing a waveform mark grab from a coincident node
Ctrl held before mouse-down forced the node/toggle win over a smaller cap or
column regardless of area; now it defers to the ordinary smallest-area
arbitration like a plain click. Also amends product docs, VERIFICATION.md, and
adds sparse-material/narrow-overlay test fixtures.
2026-08-03 16:07:49 -04:00
daniel fcd1ed022c feat(panel): add Insert as FX — ReaSampler 9000 onto the selected track, preloaded with the focused capture, over the existing instrument-drop body 2026-08-03 16:06:22 -04:00
daniel 9599e96e0c Fix drag-out giving live drags to OLE: prove the pointer left REAPER
OffReaper leaves the surface vocabulary; OsHandoff now gates on a window-ownership
proof resolved in the shell, so no in-REAPER surface can reach DoDragDrop.
2026-08-03 15:50:25 -04:00
daniel 761125d0fe view: assert the FX-park coalescing delay's debounce; correct four overclaiming doc/comment claims
Extracts parkReadyAt/parkIsReady as a tested pure fold per PLAN.md's phase
criterion; the rest is wording fixes — hitch bound, hazard width, forced-drain
scope, progressive CPU reclaim.
2026-08-03 15:50:07 -04:00
daniel 5042e2709b docs: cascade rulings R2 (band-limiting in scope) and R3 (the Omega snap fence yields)
R2 closes fork F6 IN: complex waveforms must be supported purely, so band-limited
playback enters the near-term scope alongside the loop-grid recalibration. Re-derives
the three post-R1 reliefs it spends — it reaches process(), reopens a parameter door,
and recontends the deck headroom — and mints F9-F12 for the shape it deliberately
does not rule. R3 makes the snap-radius work Phase Omega's own W2-T5 and supersedes
the zero-crossing-snap criterion at its three sites, recording that W1-T5 met it.
2026-08-03 15:37:16 -04:00
daniel d5280687f3 docs: record Daniel's static-captures ruling on the wavetable direction
R1 (2026-08-03) closes F5 — no frame-indexed wavetables yet — deferring W1 with
"yet" preserved as scope rather than rejection, and confirming W3 out. Near-term
scope narrows to W0 alone. Cascades the consequences: F1's threshold amended
(static does not mean one-cycle, so detection classifies nothing now), F2
simplified, F3 and F7 firmed, F8 narrowed to a binary. F6/band-limiting left
open and explicitly not pre-ruled. PLAN.md item 4 updated to match.
2026-08-03 15:21:03 -04:00
daniel b7b7e88195 fix(instrument): bound the waveform zero-crossing snap to a pixel radius so single-cycle loop marks stop teleporting
Ctrl during a marker drag defeats the snap outright. nearestZeroCrossing keeps its
unbounded contract; snapToZeroCrossing is the decision a drag applies.
2026-08-03 15:15:25 -04:00
daniel 59837abd00 docs: register single-cycle-and-wavetable.md in the product-doc list 2026-08-03 15:14:08 -04:00
daniel cca7d1a538 docs: frame the single-cycle / wavetable direction, eight forks open
Where the instrument's loop grid, crossfade and pitch handling stop making
sense as a capture shrinks toward one cycle, and what "wavetable synth" would
mean here. Two structural results: the pre-seam crossfade is the identity map
at one-period loop lengths, and YIN cannot detect a single cycle, so the period
is declared rather than detected there. Four candidate shapes, W3 recommended
out. PLAN.md gets a pointer under "Flagged for awareness" only.
2026-08-03 15:08:11 -04:00
daniel 6e2128e937 view: split the deferred FX-park drain by kind — restores stay forced, parks go one FX per idle tick behind a 1s coalescing delay
A rapid A→B→A flip now costs no plugin work: the restore cancels the still-pending
park outright. A park that has already written one FX carries a `partial` flag and is
superseded by its inverse rather than cancelled, so a half-parked chain is never stranded.
2026-08-03 15:00:40 -04:00
daniel 32c7e959ae Extract elapsedAtLeast to core/util, dedupe the throttle rationale, and correct the alloc-count/PLAN citations 2026-08-03 14:58:33 -04:00
daniel 170b2e4994 Throttle new-content enumeration to 500ms, off the 30/s OnTimer tick 2026-08-03 14:40:50 -04:00
daniel 1159d364c2 Merge Phase Ω-W1: mode-switch responsiveness and the control surface's second pass
Five tracks: deferred FX park with honest undo mask and synchronous drain
before persist; narrowed mode-switch persist; master meter on its own 60 FPS
timer with dirty-rect paint; deck reflow with single-button toggles and
click-to-focus overlays; one overlay-to-waveform coordinate mapping.
2026-08-03 14:33:33 -04:00
daniel 93d6fd0271 Merge Ω-W1-T4: deck reflow, focus-by-click overlay selection, single-button toggles 2026-08-03 14:30:59 -04:00
daniel d16df67eed Merge branch 'omega-w1-t3-meter-rate' into phase-omega 2026-08-03 14:21:44 -04:00
daniel c8e08a7670 Merge branch 'omega-w1-t1-mode-switch-responsiveness' into phase-omega 2026-08-03 14:21:36 -04:00
daniel c9af821eae Merge Ω-W1-T2: narrow the mode switch's persist to the one key it changes 2026-08-03 14:19:09 -04:00
daniel d278c38370 Merge Ω-W1-T5: one overlay↔waveform mapping, loop marks honest about their mode 2026-08-03 14:19:03 -04:00
daniel 78e5f06928 Drain deferred FX parks before the view model is serialized; re-validate track handles per intent; pin the restore-plan round trip 2026-08-03 14:08:28 -04:00
daniel 07b51dcabf Ω-W1-T3 remediation: meter stays live and washed under the curve popup, not frozen 2026-08-03 14:04:14 -04:00
daniel 84905e6bd1 fix(instrument-ui): raise Disabled-cue contrast, fix stale 1.2 MASTER row, close hover/focus minors 2026-08-03 14:04:00 -04:00
daniel 3137c05961 Ω-W1-T5: fix two stale comments in test_waveform_view.cpp (retired proportional-round model; sweep's structural-not-behavioural role) 2026-08-03 14:00:16 -04:00
daniel 73c69f689c fix(instrument-ui): distinguish Disabled from Off, add hover cue to mode toggles, close review minors
Adds a hairline outline for Disabled chrome buttons, resolves Hover on the four
mode-selector single-buttons, re-measures the control-surface doc's 1.2 layout
table post-reflow, and adds a structural test pinning every enable/mode toggle
to a derived segment.
2026-08-03 13:43:38 -04:00
daniel 7169d7f22b Clear the park snapshot where the restore is planned, not where it drains; make the drain re-entrant and reload-aware 2026-08-03 13:35:30 -04:00
daniel dd067a192e Ω-W1-T3 review fixes: skip meter invalidate when covered; push fast-path predicates into core
Fixes the critical finding — Browse/curve-popup no longer trigger 60 Hz
whole-client repaints when the meter is invisible. Pushes the fast-path and
self-containment geometry into master_meter with tests; batches the four minor
findings.
2026-08-03 13:31:50 -04:00
daniel e714df80f8 Ω-W1-T5 review fixes: unify the drag frame resolve onto frameToX/xToFrame; honest comments on grabbableMarks and the grey-loop-mark contrast trade 2026-08-03 13:26:44 -04:00
daniel 450559f155 deck: filter mod moves to FILTER ENV, cell runs centre in their reserves, two-segment toggles become single buttons, deck focuses its overlay 2026-08-03 13:12:28 -04:00
daniel a3853231a7 Ω-W1-T5: one overlay↔waveform mapping, and loop marks that tell the truth about their mode 2026-08-03 12:39:41 -04:00
daniel 4320bfdb08 Ω-W1-T3: run the master meter on its own 60 FPS timer, and make a meter frame cost one rect 2026-08-03 12:39:36 -04:00
daniel 82a8d51004 Narrow the mode switch's persist to the one key it changes
A switch wrote all seven ext-state keys to record that view_state moved,
re-serializing the bank book and the tracking ledger for nothing. Both entry
surfaces now take saveViewStateOnly; every other caller keeps the full save.
2026-08-03 12:39:28 -04:00
daniel a4a1c3860f Defer Design View's per-FX park to an idle tick; honest undo mask, compare-before-write, PreventUIRefresh bracket, O(1) handle resolve 2026-08-03 12:39:22 -04:00
daniel 0eb2c67875 docs: spec Phase Omega — mode-switch responsiveness and the control surface's second pass
Eight tracks across two waves, from Daniel's seven-item list of 2026-08-03; all
six forks ruled, none open. Amends instrument-control-surface.md 6.3(d) and 6.4,
both reversed by Omega-F2.
2026-08-03 11:43:28 -04:00
daniel 3376f3eaa1 Merge version bump: ReaSampler 1.6.0 2026-08-02 23:13:16 -04:00
daniel 8c1fccf450 Bump ReaSampler to 1.6.0
Phase Gamma landed the instrument's master bus, a real time-stretcher, a reflowed deck, and 44 host-automatable VST3 parameters — a user-visible capability increment, which versioning-and-release.md assigns to MINOR.
2026-08-02 23:12:58 -04:00
daniel cbc4a4c53e docs: give Phase Gamma's DAW obligations a home in VERIFICATION.md 2026-08-02 23:12:51 -04:00
daniel 27712b7573 docs: retire Phase Gamma from PLAN.md; repair stale line/symbol citations across PLAN, the Lambda build-toolchain audit note, and root CLAUDE.md 2026-08-02 22:31:54 -04:00
daniel 2ee984ed16 Merge Phase Gamma: the ReaSampler 9000 instrument grows a master bus, a real time-stretcher, a reflowed deck, and 44 host-automatable parameters
Four waves, fifteen tracks. dev's Phase E/P work was merged in first and resolved on the branch; this bubble lands the combined tree. In-DAW verification still outstanding.
2026-08-02 22:14:06 -04:00
daniel 77eb7c6680 Merge dev into phase-g: Phase Ε/Ρ and the 1.5.0 bump meet Phase Gamma's instrument work; 120/120 green
The per-directory CLAUDE.md count is re-derived at twenty-seven rather than
carried from either side. The "Decouple the instrument reload from VST3
activation" TODO entry does not survive: Γ-W3-T1 landed it, and COMPLETED.md
carries the discharge.
2026-08-02 21:57:47 -04:00
daniel 53e7d35178 docs: record the bake-prints-limiter track and close Phase Gamma 2026-08-02 21:32:04 -04:00
daniel 3313ac6b96 Merge Γ-W3-T3: the bake prints the limiter through its master stage, with the lookahead compensated so an engaged bake returns the sound the user approved and a bypassed one is byte-for-byte unchanged 2026-08-02 21:24:13 -04:00
daniel a3698972db docs: close three review nits — the build-shape index, a redundant header restatement, and the CMake guard comment
Bump the product doc's track count and list for T3; trim the header's
pre-limiter restatement now that bake_render.cpp carries it; add limiter
to the extension's not-linked enumeration.
2026-08-02 21:24:00 -04:00
daniel 0d316b7b9e Print the limiter through the bake's master stage, compensating its lookahead so an engaged bake is the approved sound and a bypassed one is unchanged 2026-08-02 20:57:29 -04:00
daniel b584fa4da9 docs: anchor the product-doc citations to symbols, not line numbers
Ten more were already pointing at unrelated code. Vendor SDK and SWELL citations left alone — those files don't move.
2026-08-02 20:46:28 -04:00
daniel 00afa9ba2e docs: anchor the checklist citations to headings and symbols, not line numbers
Line numbers rot silently. Ten were already broken, including all eight Precision invariants pointers. Vendor citations left alone.
2026-08-02 20:33:35 -04:00
daniel a9c166b8c2 docs: record Phase Gamma Wave 4's landed track and collapse the wave to it 2026-08-02 20:30:04 -04:00
daniel d3f5e6ec17 Merge Γ-W4-T1: the instrument's 44 automatable parameters under a frozen id table, both VST3 delivery channels serviced, and a stated authority model bounding a lane's authority to the window the model has not caught up on 2026-08-02 20:21:02 -04:00
daniel de34fbafdb Close two defeatable ReleaseProof guards and fix the static-lane skip's dead comparison
ReleaseProof{} and copy-reuse both compiled under this project's C++17;
user-provided ctor, deleted copy ctor and friend close them. The skip now
compares stored values, not norms, so it actually fires. Abort downgraded
to a debug assert.
2026-08-02 20:20:48 -04:00
daniel 4c6545e0d3 docs: give Phase Rho's DAW obligations a home in VERIFICATION.md 2026-08-02 20:15:56 -04:00
daniel bd7d6857e5 docs: retire the bake mono collapse and FX-GUID keying entries, file what they left behind
Both landed with accepted consequences rather than avoided ones, so COMPLETED
carries the identity move and the offline-FX degrade. Two line-ceiling entries
and the DAW-unverified GUID stability are now tracked.
2026-08-02 20:10:01 -04:00
daniel 430faff230 Merge Design View FX-GUID keying: parked FX-offline state follows the plugin, not the slot 2026-08-02 20:02:53 -04:00
daniel b5a820ed8a Merge the bake's mono collapse: a dead-center resample bake lands one channel, like every other capture 2026-08-02 20:02:47 -04:00
daniel 037707e517 docs: repoint the preamble at COMPLETED.md where the retired sections used to be 2026-08-02 19:58:48 -04:00
daniel 6787e1725c docs: the product-docs index was six files behind, including Epsilon's own spec 2026-08-02 19:52:07 -04:00
daniel 056c60c8e1 Make the automation release's publish-ordering rule a compile-time guard, not an assert
The assert compiled out under Release's NDEBUG and ran in no test target.
AutomationChannel::release now requires a ReleaseProof that only
publishLiveParams() or noRepublishNeeded() can mint.
2026-08-02 19:36:32 -04:00
daniel d5ed4f6e53 Split unidentified from missing FX in Design View drop report, fix message + console pop
Distinguish no-GUID-at-capture from identity-no-longer-live; rewrite the drop
message to state the real recovery step; mark FX-GUID stability [verify — DAW];
guard mismatched fx/fxOffline lengths; the report never force-opens now.
2026-08-02 18:51:41 -04:00
daniel 90b9d4527c Trim bake_landing comments: one-home the collapse rationale, fix a ragged header wrap
Also notes why staged.collapsed goes unread at its read site, so a future
reader won't file it as a bug.
2026-08-02 18:51:38 -04:00
daniel 5f6efb7cc3 Key Design View's parked FX-offline state to the FX's own GUID, not its slot
view_state v2 writes identities beside the v1 slot array, so a downgrade keeps
what it had. An FX gone at restore time is dropped and reported, never restored
onto whatever took its place.
2026-08-02 18:31:08 -04:00
daniel 425b9f708c Collapse a dual-mono resample bake to one channel, like every other capture
prepareLanding takes the shared collapse on the staged buffer before the hash
and the channel-count read, so hash, entry and file all derive from one buffer.
A true-stereo bake stays byte-identical.
2026-08-02 18:31:05 -04:00
daniel 02319041e3 docs: retire Phases Theta, Xi, Psi and Epsilon from the plan
All four are landed in full. Their narratives live in COMPLETED.md; the plan now carries only Gamma and Lambda. Cross-references and VERIFICATION.md citations repointed.
2026-08-02 18:27:54 -04:00
daniel 9b5393098b Close pass four: undetented host curve read, LiveValues guard, ordering assert, static-lane fix, docs
Points toPlain's exponent arm at the undetented curve map so host reads
match the editor; adds a sizeof guard plus field-poison test for
LiveValues::operator==; skips the model write when an automation value
hasn't moved; corrects five stale doc citations.
2026-08-02 18:22:21 -04:00
daniel 4231b2321c Merge version bump: ReaSampler 1.5.0 2026-08-02 17:40:40 -04:00
daniel 163c091f55 Bump ReaSampler to 1.5.0
Retires the 1.4.003 zero-padding experiment; project() and README version banner now agree at 1.5.0.
2026-08-02 17:40:30 -04:00
daniel beaf527bc6 Merge Phase Ε: the .rsbank bank package — pure codec, filesystem shell, both verbs, and the frozen compatibility corpus
# Conflicts:
#	docs/COMPLETED.md
2026-08-02 17:33:44 -04:00
daniel fa69b6c547 docs: record the millisecond floor as located and closed, and split what stayed open
The retired custom-bounds premise is corrected wherever it was encoded: the tail
bit is the time selection's, not custom bounds'.
2026-08-02 17:22:20 -04:00
daniel f33ef9af37 docs: give Phase E's DAW obligations a home in VERIFICATION.md 2026-08-02 17:19:57 -04:00
daniel a2ee5ff69d docs: the picker was never a deviation, it was a regex miss in the plan 2026-08-02 17:19:57 -04:00
daniel 24569956cf docs: record Ε-W3 as landed and Phase Ε as complete 2026-08-02 17:19:57 -04:00
daniel c0c56ab2a7 Merge Ε-W3-T1: the frozen package compatibility corpus and the harness over it 2026-08-02 17:19:57 -04:00
daniel b99027bc4c docs: collapse the landed Phase E track specs to Landed form
Five tracks across W1 and W2 now point at COMPLETED.md. W3 and the phase
header stay live.
2026-08-02 17:19:57 -04:00
daniel 3fd3214ff8 Remediate Ε-W3-T1 package-compat-fixtures review findings
Freeze *.rsbank as binary via .gitattributes; add a truncated additive_forward fixture proving the exact-size proof beats TooNew; enumerate the fixture dir to catch orphaned files; make fixture-size checks fatal instead of just logged; pin fixture version asserts as literals, not build-relative.
2026-08-02 17:19:57 -04:00
daniel 1005c943a2 docs: record Phase E waves 1 and 2 as landed
Backfills W1, which was skipped when it merged, and adds W2's two verbs.
Notes the picker deviation: GetUserFileName both directions, not the
spec'd Win32/SWELL split.
2026-08-02 17:19:57 -04:00
daniel 9521b5339f Freeze the package compatibility corpus: real .rsbank bytes proving both ladder directions, every truncation site, and the round trip 2026-08-02 17:19:57 -04:00
daniel 2069ae8086 docs: point the three bumpBankGeneration citations at the right line
A prior pass corrected session.h:108 to :114, but :114 is the read accessor;
the bump is at :121. Also corrects a module count in the package doc.
2026-08-02 17:19:57 -04:00
daniel f9b191bd36 Merge Ε-W2: bank export and bank import, both verbs and both panel rows
Union of two parallel tracks. Both action rows, both menu rows, both link
edges survive; the two package CLAUDE.md files now describe the post-merge
reality rather than either side's pre-merge scope.
2026-08-02 17:19:57 -04:00
daniel 01481350e6 Merge Ε-W2-T1: the bank export verb, its pure planner, and the .rsbank writer 2026-08-02 17:19:30 -04:00
daniel f8dde16a7e import: remediate review findings — ledger gate, docs, message split
Delegates the refuse-gate to ledgerDegraded(), lifts its console message into a
pure testable fold, fixes stale doc line citations and an inaccurate outcome-enum
comment, and splits the rename counter into collision-vs-sanitize.
2026-08-02 17:19:30 -04:00
daniel 33ea95078d docs: add the two package directories to the architecture table
Also adds the missing core/instrument/engine/loop row and corrects the
per-directory CLAUDE.md count from twenty-three to twenty-six.
2026-08-02 17:19:30 -04:00
daniel 454f67b3bc Close bank-export review findings: name-cap underflow, double overwrite prompt, test scope
Clamps insertSuffix's underflow, floors uniqueEntryName's validity guard, suppresses
the redundant overwrite confirm via a picker out-param, adds a PayloadBuffer
high-water mark, and corrects stale CLAUDE.md/CMake claims.
2026-08-02 17:19:30 -04:00
daniel a927dad2f4 import: a .rsbank lands as a new bank, whole or not at all
Four collisions answered explicitly: ids reminted, names never overwritten,
content deduped before the write, bank name auto-suffixed. Degraded ledger
refuses before the picker.
2026-08-02 17:19:30 -04:00
daniel 752d3413f8 Merge Ε-W1-T2: the package filesystem shell, pickers, and rollback journal 2026-08-02 17:19:30 -04:00
daniel 081b6f1028 package: one bank leaves the project as one .rsbank, or the export refuses and says why
Pure planner classifies missing/unreadable/unrepresentable and repairs transport
names; the verb digests, streams and commits atomically over a const session.
2026-08-02 17:19:30 -04:00
daniel f87ea3d3d7 Merge Ε-W1-T1: the pure RSBK package format, ladder, and manifest codec 2026-08-02 17:19:30 -04:00
daniel f188846370 docs(package): record known gaps and correct stale claims
Notes the export verb's overwrite-consent obligation post-append, the append's
extension-divergence behavior, and readFilePayload's 4GiB blind spot; fixes a
stale u8string() reference and marks the 4GiB guard as accepted-unexercised.
2026-08-02 17:19:29 -04:00
daniel ff65371eb6 Merge Ε-W1-T3: OriginKind::PackageImport for the file-tracking ledger 2026-08-02 17:19:29 -04:00
daniel 181b4f2edb Record parseSlots exemption and fix stale package doc comments
Documents why parseSlots skips repeat-key rejection, corrects two drifted doc
lines (naming-rule count, Malformed-after-header header validity), and records
two forward obligations for import_plan in CLAUDE.md.
2026-08-02 17:19:29 -04:00
daniel 655159ceac Close package fs review findings: readRange bounds, picker ext, non-ASCII tests
Cap readRange's allocation and reject size_t overflow instead of truncating;
re-append .rsbank when the export picker omits it; add cafe coverage for
writeFileExclusive and writeLandedFile; loop write() on EINTR.
2026-08-02 17:19:29 -04:00
daniel a197ff7d68 docs: fix overclaiming OriginKind comment and trim restated test comments
Enum comment claimed package-id lookup that no persisted field supports; reworded
to the real distinction. Trimmed CLAUDE.md-duplicated test comments and the header.
2026-08-02 17:19:29 -04:00
daniel 3909b1072c Close the RSBK name-collision class: ASCII case folding, UTF-8 well-formedness, nested-path traversal
All three are format-locked and validated on encode and decode. Repeated known
keys now reject at the root and inside an entry rather than last-wins.
2026-08-02 17:19:29 -04:00
daniel edfd7ead4d Fix the package fs seam: UTF-8 paths, GetUserFileName pickers, exclusive-create landing, rollback arm/disarm
Both pickers now ride GetUserFileName (mode 0/1); the "no save picker" premise was false.
Landing uses O_EXCL so the create is the existence check, not a TOCTOU pair.
2026-08-02 17:19:29 -04:00
daniel 35b2a3a151 tracking: append OriginKind::PackageImport as value 5
An appended field-vocabulary value, so kLedgerVersion stays 2 — pinned by a
test. Unknown kinds still degrade to Unknown with the ledger Loaded.
2026-08-02 17:19:29 -04:00
daniel 1aebf51938 Relabel post-manifest-parse failure as TooNew; refuse zero-length package entries at encode
An additively-tagged newer package that fails to parse now reports TooNew (with
writer semver) instead of unactionable Malformed. Format layer also refuses encoding
a zero-length entry, honoring the shell's appendPayload contract; both test-covered.
2026-08-02 17:19:29 -04:00
daniel 41a3016e63 Land the package filesystem shell: streaming atomic package_io, journaled rollback carve-out, asymmetric platform pickers 2026-08-02 17:19:29 -04:00
daniel e0b4ec2e21 Tighten RSBK package-format validation for review remediation
Reject NUL/control bytes and Windows-hostile names in entry names, relax
the over-broad ".." substring ban to component-only, close the
trailing-garbage gap on empty manifests, and relocate the package
CMake subdirectory to its ladder home.
2026-08-02 17:19:29 -04:00
daniel 043558a54d Land src/core/package: the pure RSBK container — format ladder, JSON manifest, framing/layout codec
Two-integer ladder (formatVersion/minReaderVersion), bare-name-only entries
validated on encode and decode, prefix decode that proves exact file size
without ever reading a payload.
2026-08-02 17:19:29 -04:00
daniel 1fd38bbd57 Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested 2026-08-02 17:16:02 -04:00
daniel 09a9ef838f 1.4.003 (Θ/Ξ/Ψ/Ρ) 2026-08-02 17:14:49 -04:00
daniel ec95aac274 Merge the settled bounds channel: the time selection is the only window REAPER is handed, and the experiment that proved it is gone 2026-08-02 17:09:46 -04:00
daniel 4c7e0507a1 Fix vacuous bounds test and stale/circular comments from the settle
Replace the self-comparing render-window loop with a genuinely discriminating
floor-vs-exact check; correct two stale claims; mark the Auto/Manual floor-parity
premise as unverified; drop the STARTPOS/ENDPOS comment's circular justification.
2026-08-02 17:08:11 -04:00
daniel 8331df2e91 Settle the render window on the time selection and delete the experiment that proved it
The millisecond floor lives in the custom-bounds field, not the engine, so
RENDER_BOUNDSFLAG=2 is now the only bounds mode: the two-position type, the
console verdict and the STARTPOS/ENDPOS drift probe all go. capture.cpp 697 -> 622.
2026-08-02 16:55:12 -04:00
daniel d85e50c143 Merge render-bounds channel experiment: hand the window over on a switchable channel, and print a verdict that is proof only when no floored model fits 2026-08-02 16:23:51 -04:00
daniel de5654fb6f Service both VST3 parameter channels, and promote pitch key-track and Trigger length so all 44 ids issue
The SDK's own single-component sample drains inputParameterChanges in
process() and implements setParamNormalized; automation was reading the
GUI channel alone. The audio thread now patches a block it solely owns.
2026-08-02 16:22:17 -04:00
daniel bcdf97d6c4 Fix render-bounds EXACT verdict: enumerate floored models instead of trusting grid membership
Grid-ness of an edge was a proxy for "no floor could explain this count," not
the test itself — equal remainders on both edges cancel under a full floor. Now
checks all three floored models directly and corrects the SHORT/LONG
floor-signature docs.
2026-08-02 16:21:46 -04:00
daniel 5f971e60cd Close three critical review findings on the render-bounds-channel verdict
Verdict can no longer print a false EXACT on an on-grid end, no longer names a
bounds channel a content-derived render never consulted, and the grid-align doc
premise is corrected without implementing it.
2026-08-02 15:56:57 -04:00
daniel bfaa0f2614 Report the instrument's automatable parameters to the host under a frozen id table, in signal-flow order, with real units
42 of 44 ids issued: pitch key-track and Trigger length stay reserved
pending a live path. Master gain reclassified Live — it never reloaded.
2026-08-02 15:14:16 -04:00
daniel 51b13304ee docs: retire Phase Rho from the plan, record it as landed 2026-08-02 15:04:19 -04:00
daniel b400384a4f Merge Phase Rho: render in place, a track's output to a new sibling with the source parked on the bench 2026-08-02 14:55:11 -04:00
daniel 7c43e55435 docs: make render_bounds_gate bullet destination-aware
Phase P split Bank-move vs ProjectMedia-leave-in-place behavior in render_bounds_gate.h but missed updating this CLAUDE.md bullet.
2026-08-02 14:54:58 -04:00
daniel d7e5c59547 Remediate Phase P render-in-place review findings
Fix the ProjectMedia refusal path's false bank claims and file relocation, an unreachable-undo idiom, and eight comment/doc accuracy issues.
2026-08-02 14:51:55 -04:00
daniel 5c0f5f1591 Render in place: a track's output to a new sibling, source to the bench 2026-08-02 14:47:59 -04:00
daniel a0fd931dcb Merge render-bounds diagnostics: prove the window at the boundary it crosses, on every tail mode 2026-08-02 14:47:19 -04:00
daniel 0ab4673887 Fix eight review findings on the render-bounds diagnostics
Corrects a false comment example, fixes two tests that couldn't detect their
own regressions, adds two more read-back checkpoints around Main_OnCommand so
a drift report self-locates, guards a spurious zero-vs-zero coincidence match,
and softens two sentences that overclaimed cause or defect.
2026-08-02 14:45:42 -04:00
daniel 292d14d14c Prove the render bounds at the boundary they cross, and name a short render whose count is exactly a millisecond-floored window
No truncation exists on our side of that boundary, so the read-back is the
only evidence available for whether REAPER kept the window — and it fires on
every tail mode, where only None was ever judged.
2026-08-02 14:28:03 -04:00
daniel c7afa3a80f docs: collapse Phase Gamma Wave 3 to its landed record, open bake-prints-limiter 2026-08-02 14:06:38 -04:00
daniel e87d044042 docs: record Phase Gamma Wave 3's two landed tracks in COMPLETED 2026-08-02 14:06:38 -04:00
daniel 5236c58838 Merge Γ-W3-T1: two categorical deck rows and a double-height MASTER bus deck, an exact filter tie-line at a 1028 row block, and the instrument reload decoupled from VST3 activation 2026-08-02 14:06:38 -04:00
daniel 7c473bce73 Merge Γ-W3-T2: the bake's reset list verified complete by construction, and the limiter's exclusion from the printed master stage recorded 2026-08-02 14:06:38 -04:00
daniel da14509ab5 Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal
The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew.
2026-08-02 14:06:37 -04:00
daniel 5c6525fb91 Decouple the instrument reload from VST3 activation, and make the master meter's accumulate exact 2026-08-02 14:02:26 -04:00
daniel 4b0b03d8d5 Fix stale post-widen deck-reflow figures (90/144/1190px) across docs and tests, and split test_deck_groups.cpp's width-budget fixtures into a new file. 2026-08-02 13:59:35 -04:00
daniel b956fe0d5a Commit the limiter's audible state on the click and defer only the host's latency restart
setInstrumentParams now arms a sticky pending restart that flushLatencyRestart drains from the sync tick; setState and the bake's adopt flush at their own tails.
2026-08-02 13:59:34 -04:00
daniel 41876674e4 Fix deck-UI review findings: right-anchor MASTER's meter column, correct stale/overclaiming comments, split test_deck_groups.cpp on its commit-tier/overlay seam, and pin two width-ceiling assertions. 2026-08-02 13:56:16 -04:00
daniel df10ddacc2 Widen the deck row block to 1028 so the filter tie-line is exact, and accumulate the meter's block peaks instead of sampling one in 47 2026-08-02 13:56:15 -04:00
daniel 0627398bbb Reflow the deck into two categorical rows plus a double-height MASTER bus deck
Row membership is now the group's own property, not a wrap outcome. FILTER's Band|Notch moves to its caption slack, which is what makes the sound row fit. MASTER gains the limiter toggle, the output meter and the GR lamp.
2026-08-02 13:52:33 -04:00
daniel f60c05c042 docs: collapse Phase Gamma Wave 2 to its landed record, and correct five spec claims the implementation disproved 2026-08-02 13:52:32 -04:00
daniel 1490c25250 Close Γ-W3-T2 review remediation: widen the bake-reset test's per-parameter coverage, correct four overclaiming comments, state the stage-time ceiling's disposition, and fix a missing include
Dials and asserts pitch-env/filter-env/trigEnv fields the sweep previously skipped in both fixtures; no reset behavior changed.
2026-08-02 13:52:32 -04:00
daniel bf7840020e docs: record Phase Gamma Wave 2's two landed tracks in COMPLETED 2026-08-02 13:52:32 -04:00
daniel e2981e83ee Bake reset: assert the limiter and bake Hold land neutral, prove the loop returns parked, and baseline the render's identity path
resetAfterBake is unchanged — every value already resets by construction. The render prints master gain but not the limiter, so §3.4's rationale is wrong; the invariant is corrected.
2026-08-02 13:52:32 -04:00
daniel 782f50c859 Merge Γ-W2-T2: an explicit loop enable, four named marks with grabbable caps, and the crossfade painted where it is heard 2026-08-02 13:52:32 -04:00
daniel d4acb3feb5 Merge Γ-W2-T1: Rate and Pitch compound into one read increment, on a three-state commit predicate and payload v16 2026-08-02 13:52:32 -04:00
daniel 1b4d0e67b7 Loop-crossfade-ux review fixes: parked-drag no longer fakes LOOP OFF, waveform label contrast fixed, hover memoizes its bank read
Also corrects the cap-area, em-dash, glyph-overhang and heuristic-comment findings noted in review.
2026-08-02 13:52:32 -04:00
daniel 56bf26d8b6 docs: collapse Phase Gamma Wave 1 to its landed record, and correct T7's superseded status 2026-08-02 13:52:32 -04:00
daniel a7c3c7a828 Loop: an explicit enable, four named marks with grabbable caps, and the crossfade painted where it is actually heard
hasLoop becomes user-owned with the gestures as shortcuts onto it; no format change. START uses overlay/trace, not accent/primary, which is the waveform's own fill.
2026-08-02 13:52:32 -04:00
daniel 35ab59d7f9 docs: record Phase Gamma Wave 1's seven landed tracks in COMPLETED, including T6 which never had a plan entry 2026-08-02 13:52:32 -04:00
daniel fefd169d5b Merge Γ-W1-T7 re-review closeout: the cadence math follows the splice jump, and the collapse band is measured rather than assumed 2026-08-02 13:52:32 -04:00
daniel cbe2369037 Bake window: derive it from the rate the voice actually reads at, so a dialled Rate or downward Pitch no longer truncates the file 2026-08-02 13:52:31 -04:00
daniel ef59265e7a Merge Γ-W1-T7 detect findings: the agreement denominator counts probes that carried signal, so an overlapping straddle can no longer turn a two-and-two split into an accept 2026-08-02 13:52:31 -04:00
daniel f1168e16eb Close Γ-W1-T7 re-review: pitch-sync cadence math, floor-model regression check, evidence-count fix, one-home comments
New cadence-collapse-band test at P=1470 shows PSOLA eliminates the corner rather than regressing it (18.52% -> 0.00%).
2026-08-02 13:52:31 -04:00
daniel 248f2f3842 PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16 2026-08-02 13:52:31 -04:00
daniel d35a55ec8e docs: correct the Gamma-W1 track count and table, and name T7 as a Rate prerequisite 2026-08-02 13:52:31 -04:00
daniel 91bd6f51a2 Period detection: silence is not dissent but an absent period is — the agreement denominator is the probes that carried signal 2026-08-02 13:52:31 -04:00
daniel cc4967d21d docs: drop the findings count from T7's open-questions bullet, it drifts as remediation lands 2026-08-02 13:52:31 -04:00
daniel 3e4ba628c3 docs: record Gamma-W1-T7 in PLAN.md, the track that landed without an entry 2026-08-02 13:52:31 -04:00
daniel 048673eb75 Merge Γ-W1-T7: Preserve's splices go pitch-synchronous — the period is detected once at load, over the loop the voice actually plays 2026-08-02 13:52:31 -04:00
daniel e7d7e70201 docs: fix stale Gamma payload-rung numbers in instrument-control-surface (v14/v15 -> v15/v16) 2026-08-02 13:52:31 -04:00
daniel 79189bd316 docs: fix stale v14 limiter rung in shell/instrument/CLAUDE.md to v15 2026-08-02 13:52:31 -04:00
daniel 9228e93750 Preserve's period detection: probes are placed by position, and a sustain loop is the span analysed 2026-08-02 13:50:14 -04:00
daniel 163ab11e05 Handle Psi's ReaperSurface::Count sentinel in decideDropClass — Gamma's exhaustive-switch gate turns it into a hard error on contact
The sentinel is not a surface, so it breaks to the existing unclassifiable-surface refusal rather than joining a real case label.
2026-08-02 13:50:14 -04:00
daniel 334022c0f1 Gamma-W1-T7: gate PSOLA's two untested wires, fix the cadence test's contradictory rationale, add a vacuity guard 2026-08-02 13:50:14 -04:00
daniel 38337229d4 docs: correct Γ's payload rungs to v15/v16/v17 after Ξ took v14
States rung one as spent rather than derivable, so following the instruction
no longer contradicts the stated number — the trap that caused the collision.
2026-08-02 13:50:14 -04:00
daniel 93230208ff Γ-W1-T7: make Preserve's splices pitch-synchronous — the jump is a whole number of the source's own period, detected once at load
30 Hz out-of-band energy 15.45% -> 0.00%; the 29 Hz rate-2.0 detune -133 -> +0 cents.
An unknown period keeps the fixed-window geometry bit for bit. The detector cannot
reach process(): sampler_core does not link it.
2026-08-02 13:50:14 -04:00
daniel f8e57a3cbd Merge Γ-W1-T2: the master bus — limiter, meter publication, and the plugin's first PDC report 2026-08-02 13:50:13 -04:00
daniel 91c1b78d5e Γ-W1-T2: the published GR meter reads the limiter, not the mute
Retire the effectiveGain blend so the meter's minimum tracks smoothGain's own
reduction against real input, unscaled by the transition mute — a toggle over
quiet material now reads no reduction instead of pinning to 0.
2026-08-02 13:50:13 -04:00
daniel 6232851c6b Γ-W1-T2: the limiter toggle is a mute, not a crossfade — the ceiling holds across both transitions
The equal-gain dry/wet blend let a peak through at (1-m) of its level. Now the
fade rides only the limited path and the hard edge lands on silence.
2026-08-02 13:50:13 -04:00
daniel 0612abbddb Γ-W1-T2 review: one restart funnel, tighter ceiling proof, effective-gain meter
Fold setLimiterEnabled's restart request into setInstrumentParams so every writer
keeps the host's latency report in sync. Pin the window-sizing identity, drop the
per-sample modulo, tighten the ceiling tolerance, publish the blended gain.
2026-08-02 13:50:13 -04:00
daniel 3baf4ee50b Γ-W1-T2: the master bus — a true-peak limiter whose ceiling is a theorem, the meter's published half, and the plugin's first PDC report 2026-08-02 13:50:13 -04:00
daniel 4fa021edae Merge Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch 2026-08-02 13:47:19 -04:00
daniel 20d86d7900 Merge Γ-W1-T3: staged contour traces draw the curve their exponent defines 2026-08-02 13:47:19 -04:00
daniel abace156a5 Fix inverted splice-cadence test: assert artifact energy, not zero-crossing period
Zero-crossing counting was anti-correlated with the real defect (splice debris
fools it). Now asserts energy outside the fundamental, with an alignable control,
matching test_preserve_low_frequency.cpp's approach.
2026-08-02 13:47:19 -04:00
daniel ab3548dced Merge Γ-W1-T1: one taper, one modifier law, the 10 s stage ceiling 2026-08-02 13:47:19 -04:00
daniel f39fb1b145 Γ-W1-T3: staged contour traces draw the curve their exponent defines
New pure curve_tessellate joins the overlay's node vertices through curveMap,
one sample per pixel column; the knot no longer floats off its own trace.
2026-08-02 13:47:19 -04:00
daniel ac653aa581 Measure Preserve's splice-alignment geometry on low-frequency material
A splice can only relocate by [0.75, 1.25]*window, so periods with no multiple
in that interval never phase-align — at 50 ms, f < 16 Hz and 26.7-32 Hz.
Harness runs by hand; too slow to gate.
2026-08-02 13:47:19 -04:00
daniel 6f39d0137a Merge Γ-W1-T4: the editor floor, the width budget, and the deck row law 2026-08-02 13:47:19 -04:00
daniel a1b42ed1a8 Fix envelope-overlay knot/trace disagreement on odd pixel spans
Generalize curveMidLevel/curveFromMidLevel to curveLevelAt/curveFromLevelAt at
arbitrary phi; knotVtx and its drag inverse now read the phi a knot's truncated
x actually implies, not always 0.5.
2026-08-02 13:47:19 -04:00
daniel ae59e9b70d Γ-W1-T5 remediation: narrow the rate-bound claim, fix baseline/measurement provenance, correct §2.4 framing
Re-derives the splice-cadence inequality and adds a corner probe that FAILS at
P=500 by design, pending a ruling. Names the baseline commit and harness edit,
fixes measurement methodology, corrects the Trigger-AHD/rate coupling framing.
2026-08-02 13:47:19 -04:00
daniel 748aad3538 Merge Γ-W1-T6: exhaustive-switch gate on pure libraries 2026-08-02 13:47:19 -04:00
daniel ee8a956fbd Γ-W1-T1 review fixes: mode-independent taper rounding, sharper drag-step test, reset-sweep verifies stored fields
Swap nearbyint for std::round (MXCSR-independent); derive the finest-drag test
from the editor floor, not the knob; verify resets against fields, not norms;
record the spline-point modifier exclusion.
2026-08-02 13:47:19 -04:00
daniel 589a8e078b Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch
Generalizes the correlation-aligned SOLA delay line so the feed and the shift are
independent rates over one ring. Unity is bit-identical to the shipped read, asserted
against a hash baseline captured pre-change.
2026-08-02 13:47:19 -04:00
daniel 3eb72d01c4 One taper, one modifier law: extract param_taper, raise the stage ceiling to 10 s, and make the AHDSR schematic axis the taper itself 2026-08-02 13:47:19 -04:00
daniel ae54ca8128 Re-derive the floor-size test fixtures from the constants and move the ceiling to sample_bands.h 2026-08-02 13:47:18 -04:00
daniel 69e2f1d3e3 Promote switch-exhaustiveness diagnostic to an error on pure libraries (MSVC + GCC/Clang)
MSVC's C4062 is off by default and GCC/Clang's -Wswitch only warns without
-Werror; this repo sets no -Wall/-Werror anywhere. /we4062 and -Werror=switch
now cover both, scoped to pure libraries only.
2026-08-02 13:47:18 -04:00
daniel abb27f08f2 Raise the editor floor to 1190x680, derived from the deck's declared width budget, and make row membership a property of the group 2026-08-02 13:47:18 -04:00
daniel e589addc54 Merge bake diagnosis soundness: every printed claim backed by an observation, write proof only where evidence crosses the plugin boundary 2026-08-02 13:45:38 -04:00
daniel 1c8709e82d Take the read-back back out of the persist; a write verdict belongs only where evidence crosses the plugin boundary
Its false gated six undo points, so an unverified byte-equality assumption could have silently removed Ctrl-Z for a bank mutation that landed.
2026-08-02 13:42:51 -04:00
daniel 25390d5253 Prove every ext-state write by reading the key back, so no guard is constant-true
SetProjExtState's return describes the whole extname. The persist and the instrument's publish now re-read their key; both refusals can finally fire.
2026-08-02 13:14:49 -04:00
daniel 2cb0b52884 docs: correct two false claims in the Phase Ε spec
REAPER's GetUserFileName serves both pickers — the no-save-picker finding was a
regex miss. A new enum value is structural, not additive: BankModel rejects an
out-of-range SourceMode/Tier rather than degrading.
2026-08-02 12:38:41 -04:00
daniel 0afb885987 Make a bake's Banked landing reachable only through the persist upgrade
The dedup path claimed it directly, so a key could be answered Ok pointing at an entry a failed persist never wrote. Write-back is now guarded and three-valued, and landing one request split out of the scan pass.
2026-08-02 12:35:06 -04:00
daniel 1800bd64c3 Prove the bake's answer writes by reading the key back
SetProjExtState's return covers the whole extname, so it never saw one key. The persist verdict now reaches the report, and a throw mid-write no longer claims the landing left nothing behind.
2026-08-02 12:00:34 -04:00
daniel 6cabbd0dd4 Fix the bake scan report's unsound absence-as-signal claim
Gate the summary on any unanswered key and on SetProjExtState's return, print one line per scanned key, and make the undo block stack RAII.
2026-08-02 08:34:59 -04:00
daniel 2f8e88880e Merge bake-answer diagnosis: five named failure modes and a scan report that says what the landing saw 2026-08-02 08:06:45 -04:00
daniel 4abeace925 Merge capture-bounds diagnosis: empirical tolerance, unmeasurable-render refusal, refused renders kept for inspection 2026-08-02 08:06:35 -04:00
daniel 41ca833b86 bake: make the landing scan say what it saw, so a no-answer names its own cause
Splits Ignore into unreadable vs not-a-request and counts every verdict; the report prints only when the pass answered nobody, so its absence proves the action never ran.
2026-08-02 08:00:19 -04:00
daniel 0511d16d4f capture: close batch-quarantine silence, 0-byte asymmetry, and round-two doc overclaims
Batch captures now name the retained-render folder once instead of nothing; Auto/Manual tail modes refuse a 0-byte render like None does; VERIFICATION.md steps 1-3 no longer invite a false conclusion; docs/comments no longer overclaim.
2026-08-02 08:00:12 -04:00
daniel ef0da893b5 docs: add Phase Λ (Linux port) to PLAN.md
Adds the Phase Λ entry: six settled rulings, four open forks, and thirteen tracks across seven waves, from docs/product/linux-readiness.md. Adds the l slug transliteration, and scopes the plan-wide "nothing unanswered" claim, which Λ's open forks break.
2026-08-02 07:52:57 -04:00
daniel c037aa1cb6 docs: rule Phase Rho's three forks
Refuse multi-track; result track always Arrange; follow panel tail. Rho-F2 overrides the mode-following framing: explicit Arrange tags on track and item, plus an explicit-tag-wins filter in detectNewContent.
2026-08-02 07:40:01 -04:00
daniel 734672ba0e docs: scope Phase Λ, the Linux port
Product doc for the extension + ReaSampler 9000 on Linux, downstream of the two Λ-W1 audits: Daniel's six rulings, seven waves, the non-REAPER-host safety contract, and four open forks.
2026-08-02 07:26:55 -04:00
daniel 2005f90c66 capture: state the bounds tolerance as empirical, refuse unmeasurable renders, keep refused ones for diagnosis
The one-frame bound is not provable for a per-edge renderer; the test now shows where it breaks. Refused renders move out of the bank instead of being deleted, so the DAW experiment has something to read.
2026-08-02 07:23:30 -04:00
daniel 61e90af547 docs: record Phase Ε fork rulings in the plan
Ε-F1 proprietary RSBK, Ε-F2 new bank with auto-suffix, Ε-F3 refuse on a
degraded ledger. No Ε track is gated now. Also carries Phase Ρ's plan
sections, authored concurrently in this shared checkout.
2026-08-02 07:06:23 -04:00
daniel 461351ee02 docs: frame Phase Rho — render in place
Product notes for rendering a track's output to a new sibling track at the exact render position, source parked in Design mode, bank never touched. Framed as a third verb (arrange->arrange), not an exception to capture/placement separation. Three open forks.
2026-08-02 07:05:33 -04:00
daniel 52394fa08f docs: fold Daniel's three Phase Ε rulings into the product doc
Ε-F1 proprietary RSBK; Ε-F2 always a new bank with an auto suffix; Ε-F3
refuse import on a degraded ledger.
2026-08-02 06:58:04 -04:00
daniel 7f3b00a646 fix: bake-answer messages stop asserting causes the classifier can't see
Rewords Cleared/Unanswered/ForeignRequest to name only observed facts and every live hypothesis instead of picking a winner; adds a guarded answeredOutcome accessor against a future unhandled BakeAnswerKind; folds an unreachable BakeScanKey/Context test state; fixes generation-stamp timing.
2026-08-02 06:57:08 -04:00
daniel 7f040dd934 Merge Λ-W1-T2: Linux source/runtime portability audit 2026-08-02 06:53:54 -04:00
daniel 47065a19bb Merge Λ-W1-T1: Linux build/toolchain readiness audit 2026-08-02 06:53:51 -04:00
daniel b1726b0ad3 Fix audit citations, counts, and the L2-07/L2-09 mechanisms after code-review verification 2026-08-02 06:39:36 -04:00
daniel f57bf4fb6d docs: fix audit citations — bad line ranges, misattributed evidence, overclaimed severity
Corrects a false unguarded-include claim, re-scopes hot-path evidence to what's actually linked, replaces a nonexistent Fork-A collision scenario, fixes several miscounted TUs/libraries, re-grades two Fork-B items from Blocker to Major/non-blocker, and adds missing owners/done-conditions.
2026-08-02 06:39:31 -04:00
daniel 962ab64ef0 bake: name the five ways the extension can fail to answer, and move the landing verdict into a pure, tab-provable classifier
A no-answer stays a failure; it now says whether the extension never ran the landing, answered a stale generation, spoke a wire this build cannot read, cleared the request, or refused it.
2026-08-02 06:38:04 -04:00
daniel a91df760cc capture: name the render source in the exact-bounds refusal, and put its one-frame tolerance under test
The tolerance is unchanged and now derived, not assumed: frameCountFor lands in {floor(L), ceil(L)}, so a non-frame-aligned window can never miss by more than a frame. Naming the source is what tells a self-bounding render from a short one.
2026-08-02 06:37:40 -04:00
daniel 86c3c7f3b8 docs: spec Phase Ε — bank export/import as a version-tagged package
Adds docs/product/bank-package.md and the Phase Ε spec in docs/PLAN.md:
three waves, six tracks. Three forks open; Ε-F1 blocks Ε-W1-T1.
2026-08-02 06:32:07 -04:00
daniel 893455594f docs: audit source/runtime Linux-portability for extension and VST3 instrument
Two one-line compile blockers, a locale hazard in the persisted number codec, and a cost split showing the extension is nearly there while the instrument editor is a from-scratch X11 job.
2026-08-02 06:07:28 -04:00
daniel 5455b1047b docs: audit the Linux build/toolchain gap ahead of Phase Λ 2026-08-02 06:07:20 -04:00
daniel 6e937b9c61 docs: add a DAW verification checklist
Checks for post-1.0 work that no unit test can close, ordered by risk. Each item cites the obligation it discharges.
2026-08-02 04:32:29 -04:00
347 changed files with 42072 additions and 5514 deletions
+1
View File
@@ -0,0 +1 @@
*.rsbank binary
+13 -8
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-three per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
Per-module detail — what each file owns, its invariants — lives in the twenty-seven per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions
@@ -84,19 +84,22 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-three directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-seven directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope |
|---|---|
| `src/app/` | REAPER extension entry point |
| `src/core/audio/` | pure audio-data math |
| `src/core/capture/` | pure logic behind the capture pillar |
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) |
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / param / ui) |
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
| `src/core/instrument/engine/loop/` | the sustain loop's ONE validity/clamp fold plus its pre-seam crossfade geometry and the editor's default handle span |
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
| `src/core/instrument/param/` | the VST3 parameter surface's pure half — the FOREVER-FROZEN id table, the exposed set derived from the commit predicate, the plain-value layer, and the one formatter per unit category |
| `src/core/json/` | the hand-rolled JSON lexical layer |
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
| `src/core/package/` | the pure RSBK bank-package codec — format contract, version ladder, JSON manifest, framing/layout codec |
| `src/core/reclaim/` | pure prune orphan computation |
| `src/core/tracking/` | the consolidated file-tracking system — birth/lineage records and the one authority answering prune's protected set and the resample's replace-vs-add |
| `src/core/ui/` | pure UI geometry, palette, and interaction-decision modules |
@@ -108,6 +111,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
| `src/shell/bank_ops/` | promptless bank-mutation verbs |
| `src/shell/capture/` | REAPER-facing capture backends and action bodies |
| `src/shell/instrument/` | ReaSampler 9000 VST3 shells |
| `src/shell/package/` | package filesystem I/O (streaming atomic read/write, exclusive-create landing), the rollback journal, and the REAPER file pickers |
| `src/shell/panel/` | the docked bank-panel shell + the shared LICE draw kit |
| `src/shell/persist/` | project ext-state persistence, prune filesystem I/O, usage scan |
| `src/shell/view/` | Design View mode application shell |
@@ -117,9 +121,10 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK
types; `shell/` is where those host types are actually touched — the discriminator is "may this
file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the
table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces
mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
table above); `core/instrument/` further subdivides into `bake/` / `engine/` / `map/` / `note/` /
`param/` / `ui/`. Namespaces mirror directories — `reasampler::<subsystem>` for `core/`, house
style for `shell/`. `app/` holds `main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and
dispatch.
For the module list within any one directory — what lives there, its invariants — open that
directory's own `CLAUDE.md` rather than looking here.
@@ -188,7 +193,7 @@ Comments carry *why*, and context where non-obvious — never *what* the code al
`docs/product/` holds the product-design reasoning behind each phase — the "why we chose this" that predates the spec. They are large; **grep for the cited section rather than reading a file whole**. `docs/cmake-cheatsheet.md` is a standalone build-system reference.
Files: `capture-tail.md`, `code-organization.md`, `design-view.md`, `midi-playback.md`, `multi-bank.md`, `provenance.md`, `removal-and-prune.md`, `versioning-and-release.md`, `visual-design-language.md`.
Files: `bank-package.md`, `capture-tail.md`, `code-organization.md`, `code-quality-audit.md`, `design-view.md`, `instrument-control-surface.md`, `linux-readiness.md`, `midi-playback.md`, `multi-bank.md`, `parameter-automation.md`, `provenance.md`, `removal-and-prune.md`, `render-in-place.md`, `single-cycle-and-wavetable.md`, `versioning-and-release.md`, `visual-design-language.md`. `audit-notes/` is a subdirectory of track-evidence appendices for `code-quality-audit.md`, not a peer doc — not enumerated above.
## Project docs
@@ -200,7 +205,7 @@ Plan-style docs live under `docs/`:
## The load-bearing principle
**Capture and placement are separate acts.** Capturing audio writes a file to the bank and adds an index entry. It **never** puts an item in the arrange view. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**.
**Capture and placement are separate acts.** Capturing audio writes a file to the bank and adds an index entry. It **never** puts an item in the arrange view. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**. A render that goes arrange → arrange, never entering the bank and never reading it (`shell/capture/render_in_place`), is a THIRD verb outside this rule rather than a softening of it — the rule binds anything that touches the bank on either side, so a bank sample may still only reach the timeline through an on-demand placement, and a capture may never grow a place step.
## Precision invariants — required before any feature ships
+4 -2
View File
@@ -21,9 +21,9 @@ cmake_minimum_required(VERSION 3.19)
# invariant (reconstruct-from-components inside app_version.cpp) via a permanent synthetic
# "0.9.01" fixture there that must NEVER be bumped on release. It cannot see this line
# becoming a CMake derivation — that is this comment's job.
set(REASAMPLER_VERSION "1.4.0")
set(REASAMPLER_VERSION "1.7.5")
project(reaper_reasampler VERSION 1.4.0 LANGUAGES CXX)
project(reaper_reasampler VERSION 1.7.5 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -85,6 +85,7 @@ set(LICE_SRC
# ---------------------------------------------------------------------------
set(REASAMPLER_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src)
set(REASAMPLER_TESTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/tests)
set(REASAMPLER_PACKAGE_FIXTURE_DIR ${REASAMPLER_TESTS_DIR}/fixtures/package_compat)
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/reasampler_targets.cmake)
enable_testing()
@@ -92,3 +93,4 @@ enable_testing()
add_subdirectory(src/core)
add_subdirectory(src/app)
add_subdirectory(src/shell/instrument)
add_subdirectory(src/shell/package)
+1 -1
View File
@@ -1,6 +1,6 @@
# ReaSampler
Version 1.4.0 · License: GNU AGPL v3 (see `LICENSE`)
Version 1.6.0 · License: GNU AGPL v3 (see `LICENSE`)
A per-project audio sample-bank capture tool for REAPER, built as two artifacts: a
native C++ REAPER extension (`reaper_reasampler`) and a Windows-only VST3 sampler
+11
View File
@@ -12,6 +12,17 @@ function(reasampler_pure_library name)
if(ARG_LINK)
target_link_libraries(${name} ${ARG_LINK})
endif()
# A default-less switch missing an enumerator: MSVC's C4062 is off by its /W1 default;
# GCC/Clang's -Wswitch is on by default but only warns without -Werror, and this repo
# sets no -Wall/-Werror/-W4/-WX anywhere. Promoted to an error only here, on our own
# pure libraries, so a deliberately default-less switch (e.g. deckParamCommit,
# deck_groups.cpp) is a compile error on every toolchain. NOT C4061 (fires even with
# a default: present) — that would light up every defensive switch in the tree.
if(MSVC)
target_compile_options(${name} PRIVATE /we4062)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${name} PRIVATE -Werror=switch)
endif()
endfunction()
# Test naming is exceptionless: target <name>_tests is built from tests/test_<name>.cpp
+779 -5
View File
@@ -6,6 +6,42 @@ original Goal, Verify, and checklist points with boxes marked done.
This file holds the current (1.x) cycle's landed milestones only. For all
pre-1.0 (version-0) history, see `docs/ARCHIVE.md`.
### Decouple the instrument reload from VST3 activation (filed follow-up, discharged in Γ-W3)
`ReaSamplerProcessor::setActive` meant two things at once — "the audio thread may run" and "the
decoded `SampleData` is (re)built" — so every host-driven activation cycle paid a bridge read
and a full WAV decode that nothing about activation required. The two lifetimes are now
separate: `setActive(false)` parks the decoded sample and destroys the voice state,
`setActive(true)` rebuilds the voices around the parked sample through the drain-slot swap
`rebuildVoiceEngine` already used for voice-count edits. A cycle costs no disk I/O and no
decode; sounding voices are still destroyed across it (a surviving `live_` would be displaced
into the drain slot and resurrect stale sustained voices as ghosts); an instance with nothing
decoded still routes through the full reload, which is where the pre-v10 legacy lift lives; and
`getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The build
shared by the reload, the voice-param rebuild and the reactivation was factored to one site so the three cannot drift
on the generation stamp or the ring size. Daniel reversed the deferral (*"I thought we agreed
to decouple the unnecessary functions from the reactivation path"*); Γ-F2 and Γ-F6 are untouched
— dynamic latency ships, the deactivate/reactivate is still the accepted cost of the toggle,
just a much cheaper one.
**`reloadInstrument` did three things, not one, and the resume path had to keep all three.** The
first review pass discussed only the pre-v10 legacy lift; the other two were dropped silently and
restored in the follow-up. `refreshRefsFromBank` — the recapture sync — and `publishUsage` — the
`rsusage_` prune-protection write — are each a `GetProjExtState` plus a parse, neither disk nor
decode, so both run on the resume path and the spec's "no disk I/O and no WAV decode" still holds
exactly. This matters only with **no editor open**: `pollBankSync`, the only other route to
either, has exactly one caller and it is the editor's sync tick. Restoring the refresh alone
would have been worse than dropping it — the refs would name a recapture's new file while the
parked PCM still played the old one — so the resume compares the selected ref across the fold
(`sameDecodeSource`, `core/instrument/map/sample_map`) and hands back to the full reload when it
moved. The decode is eliminated in the case that matters and re-run in the case that needs it.
Three smaller consequences fell out of the same pass: `setActive` now treats a repeat of the
state it already holds as a no-op (the base is an empty stub, so a repeated deactivate would have
parked an empty optional over a still-valid sample); the park is disengaged before the build
rather than left moved-from, so a throwing build cannot publish permanent silence; and
`flushLatencyRestart` checks `restartComponent`'s `tresult` and rolls its announcement back on a
refusal, since a latch on a value the host never took would strand its delay compensation.
### Comment-reduction pass (tree-wide, twelve parallel tracks)
Cut source comment volume tree-wide: 209 files changed, net **6,493** lines.
@@ -818,9 +854,28 @@ own refs table. Pre-existing — `bakeWindowNeedsHold` is only a new *consumer*
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.
seven defects and refinements (Daniel, 2026-08-01) rather than a backing product doc, so
the list is carried here verbatim rather than by reference, now that `docs/PLAN.md`'s
Phase Ψ section (its original home) is retired:
> **Ψ.1** — item and track captures should be named (labeled) after their source track
> name, plus a discriminator (date, etc.); currently they aren't named anything useful.
> **Ψ.2** — Design vs. Arrange modes: any SOLO state in one mode is cached and removed
> when switching to another mode, disjoining the solo surfaces.
> **Ψ.3** — the Design/Arrange active-mode toggle is gated if playback is running; only
> allow the switch when the project is not playing.
> **Ψ.4** — the action that moves a Media Explorer item to a new ReaSampler on the
> selected track is not in the Media Explorer action category, so it cannot be added to
> the Media Explorer toolbar; fix this.
> **Ψ.5** — drag-and-drop targets are inexact: sometimes dropping into the arrange
> doesn't work, sometimes dropping into the FX area doesn't work; dragging between banks
> is fine.
> **Ψ.6** — capture into mono: if the left and right channels of a new capture are
> bit-identical, collapse to mono — one channel of data, mono arrange items, ReaSamplers
> load in mono mode.
> **Ψ.7** — capture item / capture track with a small time selection on a large item
> captures the entire item, not the selection/razor; make capture regions consistent and
> correct.
**Ψ-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
@@ -831,7 +886,8 @@ code, which makes the byte-identity regression floor structural rather than hope
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 and self-cleans a widened render. New
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`.
@@ -883,6 +939,724 @@ 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 recorded in `docs/VERIFICATION.md`; `docs/verify-track-scope-multitrack.md`
is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track
refusal specifically. No human has observed any of these seven behaviors in a DAW.
### Γ-W1-T1 — knob-interaction-law
One consistent interaction and taper law across every variable control in the instrument,
landed before any new control (Rate, Pitch) or VST3 parameter existed so both are authored
into it rather than retrofitted. The taper is extracted into its own pure module,
`core/instrument/ui/param_taper` — the norm↔value maps (ms knobs log-scaled, semitone knobs
log2/centre-expanded) and the modifier vocabulary (`DragModifiers`, `kFineDragScale`, the
four whole-unit Shift snaps), now the single home three consumers read: the knob's needle
(`deck_values`), the AHDSR overlay's schematic axis and its drag inverse
(`envelope_overlay`/`envelope_edit`), and — from a later wave — the VST3 host's
`toPlain`/`toNormalized`. Shift snaps to whole units in the control's displayed category
(ms, semitones, percent, curve-exponent, dB); Ctrl scales the drag by 0.05; Shift+Ctrl
resolves to Shift; a mid-drag modifier press or release re-anchors value and cursor
position so the rate changes without a value jump. `resetDeckParam`'s taper bypass —
writing the default directly rather than round-tripping through `norm → value` — is now
mandatory rather than merely convenient, since the 10 s ceiling is not a power of two the
way the retired 2 s one was.
**The stage-time ceiling moves 2.0 s → 10.0 s** (`kGateStageMaxSeconds` /
`kEnvTimeMaxSeconds`, moved together so they cannot drift), reversing Γ-F3 on Daniel's
later ruling. The AHDSR overlay's schematic axis was re-derived against it: a stage's slot
width is now `slotPx × taperNorm(seconds)` rather than a linear fraction of the ceiling, so
a node's position within its slot is its knob's needle position and a short attack stays
legible at the raised ceiling instead of collapsing under a pixel. Every default gains an
exact normalized preimage under its own taper — the requirement Γ-W4-T1's
`defaultNormalizedValue` depends on, since a host's reset-to-default has no
`resetDeckParam` bypass to fall back on. The filter's four `*Norm` controls (cutoff, Q,
morph, drive) are untouched — their laws are wire-frozen in payload v9 — and the change is
persistence-neutral throughout: the payload stores raw engine doubles, so a project saved
at the old ceiling reloads with identical stored seconds and identical audio.
### Γ-W1-T2 — master-bus-audio
The master bus: a bypassable true-peak limiter, the meter's audio and publication half, and
the plugin's first latency report. New pure modules `core/instrument/engine/limiter` and
`core/instrument/engine/meter_ballistics`. Chain: voice mixer → master gain → limiter →
output bus, with the meter tapped post-limiter. The limiter is a single toggle with no
configurable controls — a baked 0.3 dBTP ceiling, default off, no makeup gain of any kind,
stereo-linked detection so the image never moves. True-peak detection is a 4x-oversampled
sidechain-only detector; the signal path itself is never oversampled. Its gain law is a
sliding minimum of the per-sample target over the lookahead window followed by a moving
average of the same width — every term of that average is a minimum whose own window
contains the sample being gained, so the ceiling holds structurally rather than by a tuned
attack, and the release only ever slows the rise. Switching is a mute, never a blend: the
unlimited signal is emitted at weight 1 or weight 0 and never in between, so the fade
always rides the limited path and the hard edge always lands on the bypassed side.
**Dynamic reported latency**`getLatencySamples()` returns 0 with the limiter off and the
lookahead in samples with it on, driving `restartComponent(kLatencyChanged)` on toggle — is
the plugin's first latency reporting of any kind; nothing in `src/` called
`restartComponent` before this track. Per block the processor publishes relaxed-atomic
peak, clip flag, and max gain reduction with no dB conversion or ballistics on the audio
thread; `meter_ballistics` (pure, unit-tested) does the conversion — instantaneous rise,
20 dB/s fall, a 1.5 s peak hold releasing at the same rate, linear-in-dB scale over
60…+6 dBFS, and a clip latch cleared on request. **Spent the phase's first payload rung**:
`kParamsPayloadVersion` reaches **15**, appending the limiter enable flag as a strict
suffix; a pre-v15 blob lifts to bypassed.
### Γ-W1-T3 — contour-trace-curves
Staged envelope segments now draw as the curve their exponent defines, closing the defect
where the mid-segment knot floated off its own trace — the paint path dropped knots and
joined the remaining vertices with straight strokes even though the exponent was already
in scope, while knot *positioning* had honoured it since Θ-W3-T2. A new pure module,
`curve_tessellate`, joins the non-knot vertices along the same curve `envelopes.h`'s
evaluators use — one point per pixel column at `start + (end start) × curveMap(phi)`
so the drawn stage and the sound it makes cannot diverge; a neutral exponent or a
zero-level span still emits just the two endpoints, matching the straight stroke drawn
before curves existed. All three envelopes (amp, pitch, filter), both play modes, every
sloped stage, share the one fix.
### Γ-W1-T4 — editor-floor-and-row-law
Commits the editor's canvas — the window floor, the width budget it derives from, and
which row each deck group belongs to — so every later UI track in the phase is drawn and
judged at the final window size rather than a size a subsequent wave changes under it.
`kEditorMinWidth` moves 980 → 1190, staying in `sample_bands.h`; `kEditorMinHeight` stays
680 (Γ-F1). `kEditorCeilingWidth` (1280, the hard cap the floor may not exceed) relocates
from `knob_deck.h` into `sample_bands.h` alongside the min-width/min-height pair, since it
is a window fact rather than a deck one; the deck's own width-budget constants — the row
block (1020) and MASTER's reserved width (142) — stay in `knob_deck.h`. The floor is
derived rather than asserted as a literal: `1020 + 12 (gap) + 142 + 2×8 (pad) = 1190`,
leaving 90 px of headroom against the 1280 px ceiling. (**Both numbers moved afterwards:**
Γ-W3-T1 widened the block to 1028 and the floor to 1198 — 82 px of headroom — so the
justification law makes the filter tie-line exact. This paragraph is what W1-T4 landed.)
Row membership becomes a property of
the group id — `DeckRow { Sound, Contour, Spanning }` plus `deckRowFor(DeckGroupId)`, an
exhaustive switch (Sound = PITCH/RATE, FILTER, VELOCITY, VOICE; Contour = PITCH ENV, FILTER
ENV, AMP ENVELOPE; Spanning = MASTER) so a future group left unclassified is a compile
error. Nothing consumes the predicate yet — the two-row arrangement inside this canvas is
Γ-W3-T1's — so at the new floor the deck still packs by the unchanged greedy whole-group
wrap, landing on two rows rather than three; the composition is knowingly interim (PITCH
ENV sits with the sound decks, both rows left-packed with dead space) until Γ-W3-T1 lands
the reflow. No drawing code, descriptor, parameter, or audio changed.
### Γ-W1-T5 — preserve-time-stretch
A real pitch-preserving time-stretcher for Preserve mode, landed a wave ahead of the Rate
control that will drive it so Rate ships onto a finished engine instead of a disposable
stand-in — moved up from a later wave on Daniel's ruling that it was the phase's longest
pole and had no UI dependency. The write rate (duration) and the tap rate (pitch) are
independent, which is the whole mechanism: a new header-only pure module, `time_stretch`,
holds `StretchCursor` — the per-output-frame source-feed schedule, a fractional cursor
carrying its rate debt, loop-wrapped — plus the measured rate bounds and their clamp,
alongside `pitch_shift`'s existing shift-ratio control. Rate 1.0 is exactly one source
frame per output frame with no residue, which is what makes the unity-ratio Preserve read
bit-identical to the pre-stretch engine — the regression floor the track is gated on, since
nothing publishes a non-unity ratio until Γ-W2-T1's Rate knob exists. No new third-party
dependency, no allocation or lock in `process()`, no dispatch on the per-sample path,
buffers sized at voice allocation or reload on `pitch_shift`'s existing pre-warm precedent.
### Γ-W1-T6 — exhaustive-switch gate on pure libraries
Merged as `ee839cf`. **This track has no entry in `docs/PLAN.md`** — the plan's Γ-W1 wave
header states so directly ("the phase's track numbering runs to T7; T6 landed within this
wave but has no entry in this document") — so this record is reconstructed from
`cmake/reasampler_targets.cmake` and the enforcement comment at its confirmed call site,
`src/core/instrument/ui/deck_groups.cpp`, rather than from a spec section.
`reasampler_pure_library()` (`cmake/reasampler_targets.cmake`) now promotes a default-less
`switch` missing an enumerator to a compile error on every pure library: `/we4062` on MSVC,
`-Werror=switch` on GCC/Clang. Neither fired before this track — MSVC's C4062 is off at the
repo's `/W1` default, and GCC/Clang's `-Wswitch` warns without `-Werror`, which this repo
sets nowhere else. The gate is deliberately **not** C4061, which fires even on a switch
that already has a `default:` clause — that would light up every defensive switch in the
tree instead of catching only the deliberately default-less ones, such as
`isLiveDeckParam` and `deck_groups.cpp`'s live-param routing, where a newly added
enumerator must be a compile error rather than a silent fall-through. Later Γ-W1 work
(T4's `deckRowFor`) relies on this gate being in place.
### Γ-W1-T7 — psola-preserve
Preserve's splices become pitch-synchronous. A new pure module,
`core/instrument/engine/period_detect` (two-pass YIN — a decimated cumulative-mean-
normalized difference picks the period, then the full-rate difference function refines it
to a fraction of a frame), estimates the source's fundamental period once at load;
`pitch_shift`'s splice jump becomes the multiple of that period nearest the fixed window
that still fits the ring's jump bound, so an aligned landing point sits at the centre of
the existing correlation search instead of possibly not existing inside it at all.
Detection runs off the audio thread by link graph — `sampler_core` does not link
`period_detect`, so no translation unit on the render path can name `detectPeriod` — and an
unknown period (noise, polyphony, percussion, a drifting source) restores the fixed-window
geometry byte for byte. A period is derived from the audio at load, so it is cache rather
than state: no `ComponentState` field, no payload rung. Merged as `7a162a5`.
**Status, corrected against what `docs/PLAN.md` currently states — the closure is now
complete, not partial.** The **geometry** failure mode (no phase-aligned landing existing
inside the search window at all, for low material such as a 30 Hz tone) was closed and
asserted at the original merge and stands unchanged. The **cadence** failure mode —
splices recurring faster than the output period at rate/shift combinations where
`window/|rate shift|` is short — was left "not closed, re-characterized rather than
fixed, no-regression asserted rather than improvement claimed" at that point, pending
remediation. Three remediation commits have since landed, including `f66b9bd` (correcting
the agreement denominator to count only probes that carried signal) and `83e7cca` — the
commit that closes the track, current tip of this merge — and a re-review confirmed the
earlier findings closed. The re-review found the original cadence analysis itself stale:
PSOLA made the splice interval follow `spliceJump()` rather than the fixed `window` the
module header still described, and the closeout supplied the missing measurement in the
collapse band. **Measured at P = 1470 frames (30 Hz at 44.1 kHz), rate 2.0 / 24 st,
against a known-answer metric floor of 55.86 % and a period-off control arm: fixed-window
excess 18.52 %, pitch-synchronous excess 0.00 %** — PSOLA eliminates that corner rather
than regressing it, with every splice at n = 1 landing exactly one source period away.
**These figures are a one-machine, Debug-build measurement against the named control arm,
not a general performance claim.**
### Γ-W2-T1 — pitch-rate-deck
PITCH became PITCH/RATE: three knobs (`Key Trk | Rate | Pitch`) under the existing
Varisp|Presrv toggle, both new controls wired through the engine. Rate is 50200 % on a
taper linear in semitones over ±12 (the stated exception to the centre-expansion law),
note-on latched; Pitch is a ±24 st baseline offset, live. Keytrack × rate × pitch-offset
compound into a single read-increment multiply — the per-sample path gained nothing.
Merged as `9dbb8b8`. Spent the phase's second payload rung, v16, as a strict suffix; a
v15 blob lifts to rate 100 % / pitch 0 st.
**`isLiveDeckParam` was renamed `deckParamCommit`** and now returns a three-state
`LiveCommit` (`Live` / `NoteOnLatched` / `Reload`) rather than a bool — one predicate
widened, not a second mechanism. **Γ-W4-T1 derives the VST3 exposed parameter set from
this predicate**, so the classification is load-bearing two waves out.
**The clamp question the plan left open at `[propose at review]` was resolved as one
clamp:** `clampStretchRate` at the stretcher, with the taper taking its bounds as
parameters and `deck_values` aliasing them from `engine::kStretchRateMin`/`Max`.
**Code review found one Critical, fixed before merge:** the resample bake derived its
frame window without the two new fields while rendering *with* them, so a bake at any
non-unity Rate — or, under Varispeed, a downward Pitch — wrote a truncated file into the
bank. Fixed by deriving the window from the rate the voice actually reads at; the
regression test judges against a measured reference render rather than a recomputed
formula, and was proved non-vacuous by reverting the fix (every non-unity case fails).
**A ruling folded in during remediation:** Pitch was an uncompensated stage-time coupling
under Varispeed — a Trigger AHD's wall-clock attack was invariant under Rate but scaled
with Pitch. Pitch is now compensated; key-track and velocity remain deliberately
uncompensated, because those are shipped sounds whose compensation would break
bit-identity at non-root notes.
**A Varispeed golden hash was added**, honestly labelled: unlike the Preserve constants
(witnessed against pre-track commit `0a7778b`), it was captured from the remediation
commit itself, so it stands as a witness for the *next* track rather than proof of this
one.
### Γ-W2-T2 — loop-crossfade-ux
An explicit loop enable, a legible mark grammar, and the crossfade painted where it is
heard. **No format change** — no `ComponentState` version moved, no new persisted field,
`resolveLoop` untouched, audio unchanged. The enable **is** `SampleLoop::hasLoop`, whose
provenance changes from marker-gesture-derived to user-owned, with the gestures as
shortcuts onto it. Merged as `a8e30a9`. A new pure module,
`core/instrument/ui/loop_marks`, holds the state machine (`resolveLoopMarks`/
`applyLoopMarks`); the four marks (START/LOOP/END/XFADE) get one grammar — line + shaped
cap + label, the cap being the grip — with cap/label/suppression geometry pure and
unit-tested.
**The crossfade moved to `[loopEnd crossfade, loopEnd)`** and draws as a
top-and-bottom edge wedge, never a second fill; the loop fill's peak alpha stays exactly
0.20.
**START draws in `overlay/trace`, NOT `accent/primary` as the spec table states**
because `accent/primary` *is* the waveform fill, so a primary START would measure 1:1
against the material it marks. `overlay/trace` measures 3.071:1 against the fill and
3.065:1 against `bg/base`, clearing the 3:1 non-text floor on both. **This is a
deliberate deviation from `docs/product/instrument-control-surface.md` §6.3's table**,
and the spec is what is wrong.
**Code review found three Majors, all fixed before merge:** a parked-vs-off state leak
where dragging START on a fresh capture silently converted "never set" into "LOOP OFF";
three text draws landing on the lime waveform fill at 1.58:1 and 1.10:1 (fixed with a
`bg/base` scrim at alpha 0.90, solved for the binding case rather than chosen by eye — it
lands at 4.666:1 / 8.091:1 and is pinned in `test_theme.cpp`); and a per-mouse-move
bridge read plus bank parse in the hover path, now memoized against the existing key
struct.
**`docs/TODO.md`'s "Pre-existing staged-envelope-node shadow at zero-attack" entry was
resolved incidentally** — giving START a cap is what closed it — and rewritten in place
with the recorded outcome by the track itself.
**One thing is deliberately NOT settled and is recorded here as open, not as accepted:**
the audible wedge draws `accent/secondary` against the `overlay/trace` envelope trace at
**1.60:1** against a 3:1 floor. The 0.20 fill-alpha constraint is met and the
pre-existing accepted 2.25:1 pair is unmoved, but the under-floor *extent* inside the
loop span grows from two 2 px marker columns to two `crossfadeWidth × 10 px` bands. No
alpha fixes it — the pair is intrinsic to teal-on-violet. **Daniel has this: he is
judging it visually in the DAW and has not yet ruled.**
**Neither track has been verified in a running DAW; both are asserted in CTest only.**
The full test suite passes on the merged result — **99/99, Debug config, on one
machine** — not a general cross-platform or Release-config claim.
### Γ-W3-T1 — deck-reflow
The knob deck's row law stops being a wrap outcome and becomes a property of the group
descriptor, by construction: two categorical rows — Sound (PITCH/RATE, FILTER, VELOCITY,
VOICE) and Contour (PITCH ENV, FILTER ENV, AMP ENVELOPE) — plus a double-height,
right-anchored MASTER bus deck outside both, carrying the limiter enable toggle, one
reserved cell, the output meter column, and a passive gain-reduction lamp. `DeckRow {
Sound, Contour, Spanning }` and `deckRowFor` (`ui/deck_groups`) are an exhaustive switch
over every `DeckGroupId`, so a group added later without a row assignment is a compile
error; the greedy whole-group wrap this replaces is gone entirely, not merely unreached at
this width.
FILTER's `Band|Notch` toggle moves from the knob row into its own caption's previously
unused second toggle slot, taking the group from 524 to 432 px (92) — the reduction that
lets row 1 (980 px natural) fit inside the row block. VOICE deliberately keeps its
`Retrig|Legato` row toggle rather than following suit: moving it to the caption would make
VOICE *wider* (226 px vs. 164), since its caption row is already the binding side.
**The row block widened 1020 → 1028 px and the editor floor moved 1190 → 1198 px
(Daniel's ruling, 2026-08-02).** The originally specified 1020 could not simultaneously
deliver the filter tie-line (both rows' FILTER/FILTER ENV right edges landing at the same
x) and equal, no-narrower-than-12px gutters on both rows — the three properties were never
jointly satisfiable at that width. At 1028 all hold: row 1's three gutters land at
16/16/16, row 2's two at 76/76, and both FILTER and FILTER ENV land their right edge at x
= 640. Ceiling headroom against the 1280 px cap is now 82 px.
The MASTER meter's per-block state moved from a plain overwriting store to an accumulated
one: at 48 kHz/512-frame blocks, roughly 47 blocks elapse between two 500 ms UI ticks, so
the overwriting store had displayed one block in ~47 and dropped the rest. The processor
now folds a per-channel peak max and a limiter min-gain across the whole interval, and the
consuming `masterBusMeter()` read clears the accumulators as it drains them.
**The instrument reload was decoupled from VST3 activation as part of this track**
`setActive(false)` now parks the decoded `SampleData` and destroys only the voice state,
`setActive(true)` rebuilds the voices around the parked sample, so a host-driven
activation cycle costs no disk read and no WAV decode. This discharges the `docs/TODO.md`
follow-up already recorded in full detail at the top of this file ("Decouple the
instrument reload from VST3 activation") — not restated here.
The limiter toggle's commit is split so that cheaper cycle stays off the mouse handler:
the audible state — the parameter, the audio-thread mirror, the latency reader — commits
inline on the click; only the host's `restartComponent(kLatencyChanged)` notification is
deferred, drained by the editor's existing 500 ms sync tick.
**Not verified in a running DAW — CTest-asserted only:** the meter at its 500 ms UI
cadence, the GR lamp under real limiter action, the limiter toggle's latency
renegotiation, the clip cap's click-to-clear, and the recapture-while-editor-closed path
(the bank fold and its predicate are unit-covered; the activation that drives them is
not).
### Γ-W3-T2 — bake-reset-amendment
The correction Phase Γ owed Phase Ξ: Ξ-W2-T1's bake shipped ahead of the sequencing this
plan asserted, so its reset list predated rate, pitch offset, the limiter enable, and the
loop enable. The finding, on reading what actually shipped: **`resetAfterBake` needed no
code change.** All four already reset by construction — none was ever added to the
survivor copy-back list, and the function's shape is "default everything, copy back only
survivors," so anything never named a survivor already resets. The track shipped
field-by-field assertions over two independently-dialled fixtures (never struct equality,
which would pass while silently letting a survivor slip through undetected) plus a
spot-check sweep confirming both fixtures actually moved every asserted field off its
default, so the coverage is mutation-verified rather than merely present.
**One invariant correction:** `bake/CLAUDE.md` had claimed the whole signal chain prints,
master gain included. It doesn't — the render's gain multiply is the only master-stage
value it prints; the limiter runs in the processor's block, off the bake path entirely.
The claim is now scoped to gain alone, with an explicit note that "the bake prints the
gain" does not generalize to the rest of the master stage.
**Outstanding, not closed by this track.** The limiter's exclusion from the printed master
stage is a real audible gap — a capture baked with the limiter engaged comes back
unlimited — and Daniel has ruled that a future track will change the bake to print the
limiter. Until that lands this is a recorded, known limitation, not an oversight.
**Neither track has been verified in a running DAW; both are asserted in CTest only.**
### Γ-W3-T3 — bake-prints-limiter
The bake's master stage now prints the limiter as well as the gain multiply, closing the
audible gap Γ-W3-T2 recorded and left open: a capture baked with the limiter engaged
returns limited audio rather than unlimited audio. `renderBake`
(`core/instrument/bake/bake_render.cpp`) instantiates its own `Limiter` — the same
bake-only-engine precedent its `VoiceEngine` already set — never linked into
`reaper_reasampler`; `src/app/CMakeLists.txt`'s exclusion comment names the limiter
alongside `sampler_core`/`pitch_shift`/the filter, so the extension's link graph gains no
new edge.
**The lookahead needed compensation, which was open at spec time.** The limiter delays its
output by `kLimiterLookaheadSeconds` (0.002 s = 96 samples at 48 kHz), so the render's
buffers carry `renderFrames() + flushFrames` frames, the extra span fed silence rather than
more rendered audio, and the capture is read out starting at `leadInFrames + flushFrames`
instead of `leadInFrames` alone — the file is the same frames it would be bypassed, not the
same capture shifted 2 ms late.
**A sequencing trap, recorded inline at the call site.** `Limiter::prepare()` ends by
calling `reset()`, which snaps to whatever the enable target already is, so the render calls
`setEnabled(true)` before `prepare()`. Reversed, the limiter would take its live-engage path
instead — `process()`'s prime-then-fade — muting and then fading in the first ~12 ms of
every capture (the delay-line prime plus `kLimiterMuteSeconds`, per `limiter.h`).
**The bypassed path is unchanged.** `test_bake_render.cpp` asserts bypass ≡ engaged
bit-for-bit under the ceiling (a ramp fixture at unity gain, verified against the source
sample for sample too) and separately confirms the printed-limiter path holds the ceiling
and stays stereo-linked under a DC fixture driven well past it; repeat bakes stay
bit-identical with the limiter engaged as well, since `renderBake` builds a fresh `Limiter`
per call and `prepare()` zeroes every one of its state fields.
**Double-limiting is a named boundary, not a defect** (`bake/CLAUDE.md`): a printed capture
replayed through an engaged limiter is limited twice. The post-bake reset ordinarily
prevents it, since `limiterEnabled` is not on the survive list.
**`bake/CLAUDE.md`'s invariant is corrected alongside the code.** The text Γ-W3-T2 left in
place ("the limiter is not printed") is replaced with "the whole chain is printed — voice,
master gain, then the limiter, in the processor's own order," and the track's three
`[propose at review]` open questions are answered inline in the same section: the bake
instantiates its own `Limiter`; the lookahead does need in-render compensation, exactly the
above; and yes, this track also corrects the invariant text rather than leaving it to a
later pass.
**Not verified in a running DAW — CTest-asserted only.**
### Γ-W4-T1 — vst3-parameter-set
The instrument now reports its automatable parameters to the host: 44 of 44 issue, under a
FOREVER-FROZEN `ParamID` table — blocks of 100 per deck group in signal-flow order, steps
of 10 within a block, a curve dial at its outer knob's id + 1 — each with a real plain
range, `units` string and display precision at the host boundary, not a raw normalized
float. The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never
hand-maintained: a control qualifies iff its class is `Live` or `NoteOnLatched`. Everything
else — play mode, the pitch engine, filter enable, the three Staged↔Spline toggles, voice
count, Poly|Mono, Retrigger|Legato, and the limiter enable — is OMITTED from the list
entirely rather than exposed read-only, a named limitation rather than a silent one. A new
pure module, `core/instrument/param` (`param_id`, `param_units`, `param_format`,
`param_live`, `param_merge`), holds the id table, the plain-value layer, the one formatter
per unit category (eight of them), and the audio thread's block-boundary merge decision;
`shell/instrument/instrument_params` adapts it onto `Steinberg::Vst::Parameter` and decides
nothing itself.
**Both VST3 delivery channels are serviced.** An earlier pass routed host automation
through `IEditController::setParamNormalized` alone — the SDK documents that as the
GUI-update channel only ("should update the according GUI element(s) only") — while
`ProcessData::inputParameterChanges` is the audio-side one; the SDK's own
`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`)
drains the queue in `process()` *and* implements `setParamNormalized`. Both are now
serviced.
**A host automation point's authority is bounded, not permanent.** It outranks the model
only between the point landing and the UI thread folding it into the model and
republishing — at most one UI tick — never a later restore, bake reset, or knob move. An
earlier pass made the hold permanent, which silently defeated `setState`, preset load,
undo, and the bake's reset for any parameter that had ever carried an automation point.
The model is now written down in full — `shell/instrument/CLAUDE.md`'s "THE AUTHORITY
MODEL" section — and enforced by the pure `param_merge`; `test_param_merge` asserts both
halves: that a held point outranks the model until the model catches up, and that a writer
after the release reaches the audio again.
**Two rulings, both Daniel, 2026-08-02.** (1) Pitch key-track and Trigger length promote
from `Reload` to `NoteOnLatched` — the promotion that takes the count to 44 of 44 and
issues ids 1000 and 1450. It was **not** the predicate-only change the plan anticipated:
key-track lives on `InstrumentParams`, not `PlaySeconds`, so the host's write path could
not reach it without `LiveValues` and `foldLive`'s input widening and `Voice::start`
taking the two latched values as arguments beside rate; the new `param::valueHomeFor`
guard closes the class of bug this exposed (a promoted control with no home would have
no-oped silently in both directions) by asserting every exposed control has a home and
branching the shell's own read/write paths on it. (2) The curve-shape dials' ±0.01
snap-to-centre band now applies on the mouse-drag path only, never on a host-facing map —
*"our continuous ranges should be continuous."*
**Two adjacent SDK surfaces were assessed and left unimplemented, with dispositions
recorded rather than re-surveyed later.** `IMidiMapping` — no CC vocabulary fits what's
exposed, and REAPER's own per-parameter MIDI learn is expected to cover the case.
`IParameterFunctionName` and `IAutomationState` are also not implemented; the latter
reports the host's automation mode for the whole plug-in, not per parameter, so it cannot
answer the bake's "is this parameter automated" question.
**The bake's reset now notifies the host, and its one remaining gap is named rather than
hidden.** Every internal writer of an exposed parameter's value goes through the one
`beginEdit`/`performEdit`/`endEdit` path, the bake's reset included. What it cannot do:
clear a host automation lane. If a reset-class parameter carries one, the lane replays its
curve onto audio the bake already baked that processing into — double processing — and
`IAutomationState`'s whole-plugin (not per-parameter) granularity means there is no way to
detect or refuse it. Documented as a boundary of the bake's fidelity claim, not discovered
later as a bug against Phase Ξ.
**The per-sample voice path is byte-identical across the whole track.**
**Not verified in a running DAW — CTest-asserted only.** `docs/TODO.md` carries the
residual DAW-verification items: whether REAPER renders `ParameterInfo::units` beside the
formatted string, whether REAPER's MIDI learn actually covers the un-shipped `IMidiMapping`
case, the three migration round trips (a pre-parameter project, a save/reopen in an older
binary, automation drawn and replayed), whether an offline render replays automation, and
whether REAPER restores instance state through `setState` rather than `setComponentState`.
### Phase Ρ — Render in place: a track's output to a new sibling, source to the bench
One wave, one track (Ρ-W1-T1 `render-in-place`), code-complete, reviewed, remediated, and
merged to `dev` as `b400384`: 91/91 tests passing, a clean build. Phase Ρ came from a
direct request (Daniel, 2026-08-02) rather than a backing product doc list — see
`docs/product/render-in-place.md` for the framing and its three [Daniel]-class forks
(Ρ-F1/F2/F3), all ruled the day the phase was framed.
**Ρ-W1-T1 — `render-in-place`.** One bindable action, `RENDER_TRACK_IN_PLACE`, renders
the selected track's output over the current range to the project's recording path —
never the bank — places it as an item on a brand-new sibling track at the exact unsnapped
render position, clones the source's colour and its name with an idempotent `Capture `
prefix, moves the source track to Design mode, and puts the result track into Arrange
unconditionally (the Ρ-F2 ruling). New `src/shell/capture/render_in_place.{h,cpp}`.
Extended `core/capture/track_topology` (`siblingPlacement`), `core/capture/capture_name`
(`captureTrackName`), `core/capture/capture_paths` (`RenderPaths`/`deriveRenderPaths`,
with `deriveBankPaths` re-expressed over it). A `CaptureDestination` enum was added to
`CaptureRequest`; `render_bounds_gate` became destination-aware. A filter added to
`panel_input::detectNewContent`, one `ActionTableRow` in `src/app/main.cpp`. All four
invariant amendments the plan required (`src/shell/capture/CLAUDE.md`,
`src/shell/actions/CLAUDE.md`, root `CLAUDE.md` §"The load-bearing principle",
`src/core/view/CLAUDE.md`) landed inline with the track.
**Four deviations worth recording:**
1. **The `activeModeId` acceptance criterion was met in spirit, not to the letter.** The
criterion said `activeModeId()` must appear only in the `applyMode` reapply. The
implementer added the spec-recommended one-line Design-fired `ShowConsoleMsg`, which
requires reading the active mode, and hoisted that read into a single named local
shared by the message condition and the reapply. All three `tag()` calls still take
literal mode ids, so the ruling the criterion protects (Ρ-F2) holds. Review accepted
this explicitly.
2. **The `panel_input` edit was larger than the spec's estimate** — the spec budgeted
"two lines only"; the landed change is six lines plus an `<algorithm>` include and
dropping a `const`, still confined to `detectNewContent`.
3. **`TrackList_AdjustWindows(false)` was included preemptively** where the spec had
asked to `[verify — DAW]` whether it is needed. Consequence worth recording: the DAW
check can no longer distinguish, so answering that question now requires commenting
the call out locally.
4. **A behavioural change beyond Ρ's stated scope**, surfaced in review and judged an
improvement: a track restored by undo now keeps its original mode instead of being
re-tagged to the active mode. Its reach is narrower than it sounds —
`ViewModeModel::reconcile` prunes records for GUIDs that have gone away, so a track
absent across a reconcile pass still falls back to the old behaviour.
Both **[propose at review]** items resolved to the plan's own recommendations: the
Design-fired console message was added (yes), and no master-track refusal was added
(no — `ResolveScopeSource` already refuses a master-only selection).
**The entire DAW-verification obligation remains outstanding.** The null test on Ρ's
own output, the three folder cases, collapsed-mono placement and summing, both mode
transitions waited out past a panel timer tick, undo, name/colour clone, and
`GetProjectPathEx` against a non-default recording path — none of it is unit-testable
and none has been run.
### The offline-render millisecond floor — located and closed (ad-hoc)
Closes the `docs/TODO.md` entry of the same name. REAPER's offline render was
intermittently refusing an otherwise-valid capture whenever the requested window's end
carried a sub-millisecond remainder — breaking root `CLAUDE.md`'s "exact bounds — no
rounding of the requested range" precision invariant.
**Located, not inferred: the floor lives in the `RENDER_BOUNDSFLAG=0` custom-time-bounds
field, not in REAPER's render engine.** Switching the offline render to
`RENDER_BOUNDSFLAG=2` (the project's own time selection, driven through
`GetSet_LoopTimeRange`) escapes it entirely. Confirmed by two live 48 kHz
`TailMode::None` DAW renders, both landing exactly 97627 frames against the window's own
count: the first started at the on-grid `0s` and tested only the END edge (a floored end
would have printed 97584 — 43 frames short); the second, the decisive run, started at
`2.0338983050847457s` and ended at `4.0677966101694913s`, both edges off the
millisecond grid, and no millisecond-floored model of either edge alone or both together
reproduces 97627. No compensation, trimming, or extraction was needed.
`src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection` is now the one
narrative home for the mechanism and the measurement; time selection is the only bounds
mode the offline render reaches.
**Two hypotheses this track's originating entry previously carried are disproven, not
merely superseded** — both predicted a shortfall tracking the render's CONTENT: that the
render bounds itself to the media it can see, and that a trailing-silence trim fires
despite `RENDER_NORMALIZE`. The measured cause tracks the WINDOW instead — the exact
millisecond-floored count, independent of what the material does. Recorded so neither is
re-proposed without a fresh observation.
**Scaffolding removed.** The experiment's apparatus — a two-position
`RenderBoundsChannel` type, a console verdict line, and a three-checkpoint
`RENDER_STARTPOS`/`RENDER_ENDPOS` read-back probe — is deleted now that the mechanism is
settled.
**Left open, filed to `docs/TODO.md`:** `renderHonoredBounds`'s one-frame tolerance
remains empirical, not proven; and `TailMode::Auto`/`Manual` have no automatic bounds
observation at all — `render_bounds_gate` judges `TailMode::None` only, so both modes are
fixed by inference (same bounds path, same floor) rather than by measurement, and only
the 0-byte gate covers them until a DAW check closes it.
### Ε-W1 — The contract, the filesystem, and the ledger's new kind
Phase Ε's first wave: the `.rsbank` package contract, the filesystem/dialog seam
behind it, and a new tracking-ledger origin kind for package-sourced files — three
tracks, disjoint by directory, dispatched in parallel.
**Ε-W1-T1 — `package-format`.** The pure `src/core/package/` codec for the
hand-rolled `RSBK` container (Ε-F1, ruled — no ZIP, no compressor, no link edge to
`vendor/WDL/WDL/zlib/`): a fixed little-endian header carrying two version
integers — `formatVersion` (what the writer emitted) and `minReaderVersion` (the
oldest reader that can read it safely) — a length-prefixed JSON manifest, and
payloads concatenated in manifest order. `classifyPackageVersion` answers
`Readable`/`TooNew`/`Malformed`; a `TooNew` header refuses whole, producing no
manifest, so the refusal can still name the writer's semver rather than
half-succeeding. Landed as three modules: `package_format` (the contract, the
version ladder, and three name-validation rules — `isValidEntryName`,
`sameEntryName`'s ASCII-case fold, `isValidNestedSamplePath`), `package_manifest`
(the manifest model + JSON codec, carrying the bank's `slot_map` and a whole-file
`hashBytes` digest per entry — deliberately not `hashWavContent`, which skips
chunks and so cannot answer "did these bytes survive"), and `bank_package`
(framing/layout arithmetic: `encodePackage`/`decodePackage`/`requiredPrefixSize`,
never holding or hashing a payload itself). Hostile input is refused, never UB,
at every byte offset.
**Ε-W1-T2 — `package-fs-shell`.** `src/shell/package/`: streaming, atomic package
filesystem I/O (`package_io`'s `PackageFileWriter`/`PackageFileReader`, at most one
entry's payload materialized at a time, backed by a `.rsbanktmp` sibling that
reaches the destination only through a `commit()` rename — process-crash atomic,
not power-loss atomic, deliberately, since an `fsync` over a whole sample bank is a
real stall) and the rollback journal (`package_rollback`'s `LandedFileJournal`,
citing the `prune_fs.cpp` carve-out rather than restating it, disarmed only after
the caller's own write has returned success). `package_pickers` rides REAPER's own
`GetUserFileName` for both directions, as specified (mode 1 import, mode 0 export)
— the plan's "REAPER has no save picker" finding was a regex miss in the original
research, not a real gap, so there was no asymmetric-picker deviation to land: no
SWELL `BrowseForSaveFile`, no Win32 `GetSaveFileNameW`, no `GetUserFileNameForRead`
(the SDK header marks it superseded). REAPER owning the dialog on every platform is
why there's no platform split; that's separate from `main.cpp` already aborting
extension load if any needed API pointer fails to resolve, which is why no fallback
path is needed. Both pickers are `[verify — DAW]`, never exercised in a live REAPER
session.
**Ε-W1-T3 — `import-origin-kind`.** `OriginKind::PackageImport` appended to the
tracking ledger as value 5 — package-sourced vs `Ingest`'s user-picked. Append-only,
per `core/tracking/CLAUDE.md`'s persisted-integer rule; an unrecognized kind
degrades to `Unknown` rather than failing the parse, and `kLedgerVersion` stays at
2 — a vocabulary addition, not a document-version bump. No decision surface
changed: `pruneProtection`'s output is unaffected for every existing kind.
### Ε-W2 — The two verbs
Two tracks landed on Ε-W1's contract: a bank leaves the project as one `.rsbank`
file, or the export refuses and says why; a `.rsbank` becomes a **new** bank,
completely or not at all. Both tracks were code-reviewed and remediated before
merging; the merged tree (Ε-W1 + Ε-W2) builds clean and passes 100/100 tests.
**Ε-W2-T1 — `bank-export`.** New `core/package/export_plan` (pure: which entries,
what names, what is missing, and therefore whether the export may proceed — verdict
`Ready`/`Incomplete`/`Refused`) and `shell/package/export_bank` (the promptless
verb, in three composable public steps — `surveyBankExport`, `digestSources`,
`writePackageFile` — arriving with a **const** `ReaSamplerSession&`, so "writes no
ext state, opens no undo point, never bumps the generation" holds by the type
rather than by memory), plus `shell/actions/package_export_action`, one
`main.cpp` action-table row, and one panel bank-menu row. Nothing is re-encoded;
payloads are copied and hashed. The exported unit is one bank — the pool included,
since the pool is structurally one `BankIndex` among many — and whole-book export
stays out of scope for the phase. Both open questions were answered at review:
affordance ships as **both** the bindable action and the panel row, and the
default file name derives from the bank's display name through
`capture_paths::sanitizeStem`.
**Ε-W2-T2 — `bank-import`.** New `core/package/import_plan` (pure: the id remap
table, the parent remap, the per-entry land/skip-already-present/rename
disposition, and the destination bank's display name after `BankBook`'s own
uniqueness fold — reached through a new additive `BankBook::uniqueDisplayName`
member, the only `core/model/` edit in the phase), and on the shell side a
REAPER-free `import_landing` (decode, verify every payload's `hashBytes` digest
against the manifest BEFORE the bank folder is created, then land through the
rollback journal) plus a REAPER-facing `import_bank` (the only piece touching the
extension's project state — the undo-batched persist and the generation bump),
`shell/actions/package_import_action`, the panel's `.rsbank` drop route, one
`main.cpp` row, one panel menu row, and a new `src/core/util/ascii_ws.h`. The
tracking-ledger guard runs before the file picker opens (Ε-F3, ruled: refuse
outright on `Unreadable`/`FutureVersion`, no confirm-and-proceed); the version
gate runs before any byte is written; all four collision classes — sample id, file
name, content hash, bank display name — are answered explicitly, with the
display-name collision auto-suffixed and never prompted (Ε-F2, ruled: always a new
bank, never a merge); birth records land via
`recordCreated(sample, OriginKind::PackageImport)` in the same straight-line block
as the index add; the index mutation is one Ctrl-Z, and the landed files'
survival as orphans until the next prune is stated in the user-facing summary, not
left implicit. **Beyond spec:** `import_plan`'s `spelledLikeABankFile` mints a
fresh name even absent a collision, whenever the package's own entry name isn't
spelled the way `deriveBankPaths` would spell it — counted separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
a hostile or foreign-spelled entry name (e.g. an unexpected extension) always
lands sanitized rather than verbatim.
### Ε-W3 — The compatibility fixtures
The phase's third and final wave, and with it Phase Ε's implementation is complete: the
version-compatibility policy stated in `docs/product/bank-package.md` is now a property
proven against frozen bytes rather than an assertion in a doc.
**Ε-W3-T1 — `package-compat-fixtures`.** A new checked-in corpus of 23 frozen `.rsbank`
fixtures under `tests/fixtures/package_compat/` — one v1 package written by the shipping
build (`1.4.0`), a synthetic additive-forward package (`formatVersion` 2 /
`minReaderVersion` 1) carrying three keys this build has never heard of, a synthetic
structural-refusal package (2/2), nine truncations (one per distinct decode failure
site, including one cut at `additive_forward.rsbank`'s own payload boundary), and eleven
hostile-name packages (six bad entry names, five bad nested `relativePath` values) —
every payload a single 300-byte 16-bit mono WAV, ~15 KB for the whole corpus. Two new
test targets decode and exercise it: `package_compat_tests` (frozen bytes decode to
exactly what the shipping build wrote, the additive fixture reads with every unknown key
skipped, every truncation classifies `Malformed` and never `TooNew`, every hostile name
is refused before any planner runs) and `package_round_trip_tests` (the same corpus
driven through the actual verbs — export → import → export over `v1_shipping.rsbank`
yields byte-identical payloads, and every refusal fixture refuses the whole import with
nothing landed and nothing in the index). A new repo-root `.gitattributes` (`*.rsbank
binary`) is load-bearing, not decoration: under `core.autocrlf = true`, git's NUL-sniffing
heuristic would text-classify a future short, ASCII-heavy fixture and CRLF-mangle it on a
Windows checkout, silently breaking the frozen-bytes premise the whole corpus rests on. A
standalone DAW verification script, `docs/verify-package-transfer.md`, covers the one
claim no unit test can make — a real cross-machine transfer, including the too-new
refusal, the truncated-download refusal, and mid-payload corruption, each read off as an
exact message string. **Open question resolved:** the recommendation (one-sample
packages, a few hundred bytes of payload each) was followed — the corpus holds
one-sample packages with a 300-byte payload each. **Deviation from spec:** the plan
called for a truncation cut mid-layout; RSBK stores no layout section (the layout is
derived from the manifest's entries, not stored as its own section), so the fixture that
exercises "the manifest parses, the layout computes, the exact-size proof fails" lands at
the payload boundary instead. No production module was touched — the wave adds test-tree
files, the corpus, its README, the verification script, and one path variable in the root
`CMakeLists.txt`.
### Resample-bake mono collapse — closes the `docs/TODO.md` deferral (ad-hoc)
`prepareLanding` (`src/shell/capture/bake_landing.cpp`) now applies the shared lossless
mono collapse to the staged buffer — via a new thin wrapper `applyMonoCollapse` in
`src/core/capture/wav_codec.cpp` — before the hash and the channel-count read, so the
hash, the entry, and the written file all come from one collapsed buffer. A dead-center
(dual-mono) bake now lands as a 1-channel file exactly as a dead-center offline capture
already does; a true-stereo bake is byte-identical to before, asserted on bytes and on
hash.
**The blocker this deferral originally cited has cleared.** `bake_land.cpp` was
another team's freshly-landed remediation surface at the time; that remediation has
since landed, which is what made taking this item this wave safe.
**Consequences accepted, not avoided:**
- A dead-center bake's **content identity moves** — the hash now covers the collapsed
bytes, so a dual-mono bake will not hash-dedup against a stereo twin already in the
bank, and its derived file name changes. This was already documented as accepted for
the other capture paths in `src/core/capture/CLAUDE.md`; the bake path now inherits
it rather than being an exception.
- `BakeOutcome::channelCount` now answers 1 for a dead-center bake, which flips the
instrument's channel-mode auto-default to Mono. Safe: the audio is identical either
way when the source was dead-center, and the consuming site was already written
anticipating that value.
### Design View FX-GUID keying for `restoreFxOffline` — closes the `docs/TODO.md` deferral (ad-hoc)
`restoreFxOffline` (`src/shell/view/view.cpp`) now returns each parked track's per-FX
offline state to the plugin it was captured from, keyed by the FX's own GUID
(`TrackFX_GetFXGUID`) rather than its slot index. New pure module
`src/core/view/fx_offline.{h,cpp}` holds the keying types (`FxKeying`: Identity/Slot),
the per-FX snapshot/plan types, and `resolveFxRestore`, which matches each captured
state against the chain as it stands at restore time. `view_state` gained a v2 schema
that writes the identity array beside the v1 slot array, so an older build reading a
v2 blob keeps the behaviour it had rather than losing every FX state.
**Consequences accepted, not avoided:**
- A dropped FX (one whose captured identity is no longer live in the chain at restore
time) is left **offline**, as park left it, with its snapshot already cleared — the
console report names the drop and the recovery.
- The console report uses the quiet `!SHOW:` form on every path, so it never
force-opens the console window.
**Left open, filed to `docs/TODO.md`:** FX-GUID stability itself — whether
`TrackFX_GetFXGUID` survives a chain reorder while parked — is unverified in the DAW
(SWS issue #802 names a specific way it might not hold).
+1938 -2053
View File
File diff suppressed because it is too large Load Diff
+322 -121
View File
@@ -110,20 +110,6 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Done looks like.** Not stated in PLAN.md.
## FX-GUID keying for `restoreFxOffline` (Design View park/restore)
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): the bulk of reconcile residuals shipped (`ViewModeModel::reconcile(liveGuids)` prunes orphaned snapshots on every toggle/load; folder restructure is self-healing because the tree is rebuilt each toggle; membership is intentionally kept so undo-delete preserves the tag). Two sub-items were left deferred out of that; this is the first.
**The wart.** `restoreFxOffline` currently restores per-FX offline state by slot index. If the FX chain is reshuffled while a track is parked, restore lands on whatever plugin now occupies that slot rather than the plugin it was originally captured from.
**Intended fix.** FX-GUID keying — key the per-FX offline snapshot entries by FX identity rather than slot index.
**The constraint the fix MUST handle.** The keying change requires a snapshot-schema migration; CONTEXT.md names this alongside the keying change as the reason the fix was deferred rather than folded into the reconcile-residuals work.
**Priority / risk.** Not stated in the source.
**Done looks like.** Not stated in the source beyond the fix description above.
## Dormant membership entries in persisted `view_state`
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section), the second of the two sub-items left deferred after the reconcile-residuals ship described above.
@@ -172,13 +158,7 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected.
**Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart.
**The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules.
**Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all.
**Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended.
**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — but the cap slot's own nominal area DID move, from the old clipped-actual measure (60 at frame 0) to the new nominal 110 every cap now feeds it (`markerHandleRect`'s own unclipped area). That move leaves the `cap < node < column` ordering unchanged only because 110 is still under the node's fixed 169 — the outcome held, not the area. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged.
## Active-bank indicator placement (B4 polish)
@@ -246,57 +226,35 @@ alpha and this entry is re-filed against the new value.
**Nothing here is actionable as a TODO.** Delete this entry when Γ-W1-T1 lands.
## Decouple the instrument reload from VST3 activation
## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling
**Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed).** Γ-W1-T2 ships the plugin's
first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead
with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The
vendored SDK defines that flag as a host **deactivate/reactivate**
(`pluginterfaces/vst/ivsteditcontroller.h:105-108`). **Dynamic latency reporting is routine for
VST3 instruments and REAPER handles it as a matter of course** — the deactivate/reactivate is
the normal contract, and for a typical plugin `setActive` only allocates and frees buffers.
Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it:
*"you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC
dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter
will either be on or off on its instance, toggling during playback is not a use case."*
**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the
~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the
Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_`
plus the per-gesture anchors it is read against.
**The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls
`reloadInstrument()` (`src/shell/instrument/reasampler_processor.cpp:89-97`) — a bridge read
plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`,
`draining_` and the graveyard (`:98-107`). So every host-driven activation cycle — a
latency-change restart, an offline-render bracket, any host that deactivates around transport
state — pays a disk read and a decode that nothing about activation requires. **Activation
currently means two things at once**: "the audio thread may run" and "the decoded `SampleData`
is (re)built." Dynamic latency is simply the first feature that makes the cycle
user-triggerable.
**Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs**
(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of
the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and
three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant
observed rather than enforced. Extracting it is a real refactor of the editor's input half, not
a header move — and doing it inside a wave whose subject is the MASTER deck would have put an
unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded
was not.
**Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a
deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this
file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the
already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode
edits. This is a lifetime split, not a new mechanism.
**The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload,
with the input TUs mutating it through named transitions rather than assigning `drag_` and its
anchors independently — which is also what would let the invariant "an anchor is only readable
while its own `DragKind` is in flight" be enforced rather than observed. `editor_interaction.h`
already holds the `DragKind` vocabulary and is the natural home.
**The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its
reason is documented at the call site: a surviving `live_` would be displaced into the drain
slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still
die across the cycle** — only the decoded PCM survives, and those are two different lifetimes
currently collapsed into one. Second constraint: `setActive(true)` is also the non-editor
legacy-lift trigger for a pre-v10 blob (its opportunistic `refreshRefsFromBank` copies refs in
once the bank blob is readable), so a path that skips the bridge read must keep that lift
reachable — the comment at `:90-96` records the residual load-order race it exists to cover.
**Priority / risk.** Low, but the margin is the clock: the next surface that adds two members to
the header takes it over the ceiling, and at that point the seam gets chosen under time pressure
by whoever is unlucky. Take it before that, not after.
**Priority / risk.** Low; deferred by ruling. Nothing is incorrect today, only wasteful, and
Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter
toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second
latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the
limiter enable is ever wanted automatable, which `docs/product/parameter-automation.md` §3.8
currently forbids *because* of this cost; or (c) the re-decode is observed to be perceptible in
REAPER — Γ-W1-T2's review records that observation for exactly this purpose.
**Done looks like.** A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV
decode; sounding voices are still destroyed across it, with no ghost-resurrection regression;
a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather
than from a transient the deactivate cleared.
**Done looks like.** `reasampler_editor.h` is back under the ceiling with room; no TU assigns
`drag_` and an anchor as two independent writes; and the transitions are named where the
`DragKind` catalogue already lives.
## `Sample::sourceMode` has no value meaning "produced by the instrument"
@@ -346,6 +304,15 @@ buffer.
shared with every other caller in `core/capture/wav_codec`; a fix must not change
those callers' contract or add a second WAV-building code path to maintain.
**Re-confirmed still accurate (2026-08-02), after the mono-collapse landing touched
`wav_codec` adjacent to this site.** `applyMonoCollapse` operates on the staged bytes in
`bake_landing.cpp`'s `prepareLanding`, upstream of and unrelated to `runBake`'s
`std::vector<double>` copy in `instrument_bake.cpp`; `buildFloat32Wav`'s signature is
unchanged. The wart stands exactly as described above.
**Current blocker.** Not taken this wave because `instrument_bake.cpp` is being edited
by a live VST3-parameter track.
**Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review; correctness is
unaffected, only peak memory on a large bake.
@@ -371,7 +338,7 @@ The within-deck stacking idea is retired, not deferred.
**The measured-geometry block that used to live here has been deleted, not moved.** It was
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
group's width, both row totals, and the resulting 1190 × 680 floor — is the table in
group's width, both row totals, and the resulting 1198 × 680 floor — is the table in
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
them; it was measured against a layout that no longer exists.
@@ -535,7 +502,7 @@ needs no live REAPER process to exercise `rec->Register(...)` calls. Once
**The constraint the fix MUST handle.** The extraction alone buys nothing:
`action_registry` has no test target today either, so lifting `ingestHandleSectionCommand`
into it without also standing up the test target just relocates the untested code. The
same follow-up could collapse `ingest.cpp:466-472`'s hand-rolled `command_id`+`gaccel`
same follow-up could collapse `ingest.cpp`'s `ingestRegisterActions` hand-rolled `command_id`+`gaccel`
pair onto `action_registry::registerAction`, which already does exactly that dance for
the Q-W6 table.
@@ -570,7 +537,7 @@ track", nothing to do and the inference is retired into fact. If it comes back "
summed file", the refusal is over-strict for the TRACK scope and should be narrowed back
— and the ITEM-scope half is then an OPEN question, not settled: a full-extent item
capture already sums a multi-track item selection via `&32|single-file`
(`tests/test_render_settings.cpp:262`), so if `&128` also sums, a ranged item capture
(`test_render_settings.cpp`'s `testMultiTrackStemRenderIsNamedForRefusal`), so if `&128` also sums, a ranged item capture
routed through it sums too, and keeping the item refusal in that branch would make item
scope inconsistent with itself across the range boundary (full-extent sums, ranged
refuses, same scope). Whether that inconsistency is acceptable or the item refusal should
@@ -650,58 +617,292 @@ select/move the neighbour, or capture at track scope instead.
non-isolation as an oversight and re-propose closing it against the recipe's stated
tracks-and-range-only shape.
## Resample-bake landings don't apply the lossless mono collapse to a dual-mono render
**Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono`
/ `core/capture/wav_codec::collapseToMono`) ships for every extension capture path —
offline, realtime, batch, recapture — but not for `bake_land.cpp`'s `landOne`, the
resample bake's landing function. A dead-center instrument render (the common case
that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate
collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need.
**Not deferred for the reason once given.** `landOne` reads the staged file into `bytes`
once (`bake_land.cpp:101`), parses its layout (`:105`), hashes it (`:126`), derives the
channel count twice (`:131`, `:178`), and writes it (`:165`) — all from that same one
buffer, so collapsing `bytes` right after the layout parse would keep the hash, the
channel count, and the written file consistent by construction; there is no ordering
hazard here to defer around.
**The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface
(Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing
a mutation there now would cross tracks mid-remediation for no urgent gain — the mono
propagation this item would add is a size win, not a correctness one.
**A mono capture already propagates through the bake for free**, so this item is scoped
to the dual-mono-*render* case only: `runBake` / `instrument_bake.cpp` already renders
however many channels the dialed sound has, and `bake_render.cpp:38` reads
`sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed
sound already bakes to a mono file today, with no change needed.
**Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on the staged
`bytes` in `landOne` right after the layout parse (`:105`) and before the hash (`:126`),
matching the offline/realtime insertion point (post-parse, pre-identity-read).
**Priority / risk.** Low — a size optimization on an already-correct path, not a
precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger)
stereo file today.
**Done looks like.** A dead-center instrument bake lands as a 1-channel file with
`Sample::channelCount` matching, the same way an offline dead-center capture does; a
true-stereo bake is byte-identical to today's output.
## A 0-byte render can pass every gate and land as `Ok` (pre-existing, not a Ψ-W3 regression)
## A 0-byte render can still pass every gate under Auto/Manual tail (closed)
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
(`capture.cpp:489`) passes for a 0-byte file, and the bounds gate (`:507-546`) only fires
when `expectedFrames > 0` — an invalid/empty layout reads `expectedFrames == 0` and skips
the gate rather than refusing. A 0-byte render can therefore reach `stampCaptureSample`
and land as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
passes for a 0-byte file, and the bounds gate used to fire only when `expectedFrames > 0`
— an invalid/empty layout read `expectedFrames == 0` and skipped the gate rather than
refusing, so a 0-byte render reached `stampCaptureSample` and landed as
`CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
**Not introduced by Ψ-W3.** The exists-check and the `expectedFrames > 0` guard both
predate this track; Ψ-W3 only added the mono-collapse failure report that sits downstream
of this hole and was careful not to assert bytes it never verified (see
`reportCollapseFailure` in `capture.cpp`).
**Narrowed, then reopened as an asymmetry.** `shell/capture/render_bounds_gate` was
first changed to refuse an unmeasurable render (invalid layout, or a layout declaring no
sample rate) instead of skipping it — but that gate only ever judges `TailMode::None`,
so a 0-byte render under Auto/Manual still landed as `Ok`, while `None` now refused and
quarantined the identical file. The two tail modes disagreed on a defect neither should
accept.
**Intended fix.** After the exists-check, also reject a 0-byte file explicitly (its own
status, not folded into `BoundsMismatch`, since a 0-byte file was never bounds-checked at
all) before anything downstream reads it.
**Closed.** `capture.cpp` now checks `checkRenderedFileNotEmpty` right after the
exists-check, on every tail mode, before the `TailMode::None`-only bounds gate runs —
a 0-byte render is refused and quarantined identically regardless of tail mode. The
refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own status; the
earlier note here preferred a distinct status, and that preference is unresolved, not
withdrawn.
## `renderHonoredBounds`'s one-frame tolerance is empirical, not proven
**Context.** The millisecond-floor defect that motivated this gate is closed
(`docs/COMPLETED.md`), but the gate itself — `render_window.h`'s
`renderHonoredBounds` — carries a one-frame tolerance that carried through the fix
unchanged and was never itself proven.
**The wart.** A renderer that resolves the window's two edges by DIFFERENT
conventions can sit two frames from `frameCountFor`'s answer on a
correctly-honored render. That cannot account for the 8- and 38-frame shortfalls
the floor produced (`docs/COMPLETED.md`), so it was not the cause of those
refusals — but it means a future one- or two-frame refusal may be the gate's own
edge convention rather than a real defect.
**Intended fix.** Not proposed. Widening the tolerance is a precision-invariant
decision, not a bug fix, and was deliberately not taken on speculation.
**Priority / risk.** Low. Nothing to date implicates the tolerance itself;
recorded so a future narrow refusal is investigated rather than assumed to be
the same floor.
**Done looks like.** Either the tolerance is confirmed correct by a DAW
observation that isolates edge-convention behavior from bounds-floor behavior,
or it is widened with the reasoning recorded.
## `TailMode::Auto` and `Manual` have no automatic bounds observation
**Context.** `render_bounds_gate.cpp`'s `checkRenderedBounds` returns early for
anything but `TailMode::None`, so the millisecond-floor fix (`docs/COMPLETED.md`)
was measured only against `TailMode::None` — Auto and Manual were never
observed, before the fix or after it.
**The wart.** The inference that Auto/Manual are fixed too is sound — same
bounds path, same floor, same fix — but it is an inference, not a measurement.
`checkRenderedFileNotEmpty` runs on every tail mode and still catches a 0-byte
render, but that is the ONLY automatic bounds signal Auto/Manual get; a
floored or otherwise short-but-nonzero render under either mode would land as
`Ok` with nothing to catch it.
**Intended fix.** Not a code change — a DAW observation. `docs/VERIFICATION.md`'s
"Capture range and bounds" section already carries the manual check: repeat an
off-grid-start capture at Manual over a source loud to the window's end and
check the landed frame count against window + `tailMs`; Auto can't be checked
by count (it trims trailing silence) and needs the null test by ear/inversion
instead.
**Priority / risk.** Low. Both modes share the same bounds path as the
now-fixed `TailMode::None`, so nothing suggests they still floor — but nothing
confirms it either.
**Done looks like.** A DAW-observed Auto and Manual capture, each landing the
window as requested, closes the inference into fact — or surfaces a
mode-specific divergence this entry does not currently know about.
## Floor, ceil and round are not the identity on a millisecond grid point in binary double (caution, not an open question)
A discarded compensation design for the millisecond-floor defect
(`docs/COMPLETED.md`) rested on the premise that a grid-aligned value survives a
bare floor/ceil/round unchanged. That is false in binary double: `1.007 * 1000
== 1006.9999999999999` (floors to 1006, not 1007), and `4.068 * 1000 ==
4067.9999999999995` (floors to 4067, not 4068). The compensation this premise
would have supported is no longer needed — the fix moved the render to a bounds
mode that does not floor at all — so this is not a live open question. Recorded
because it would bite any future millisecond-grid arithmetic that assumes an
on-grid value is safe from a bare floor: `render_window.h`'s own
`isOnMillisecondGrid`/`msFlooredEndFrameCount` already carry the nanosecond
tolerance that handles it correctly on this codebase's side of the boundary; the
trap is for whoever writes the next piece of grid arithmetic without that guard.
## `capture.cpp` is over the ~600-line ceiling — the seam is identified, taking it is blocked
**Context.** Removing the settled bounds experiment's instrumentation (the console
verdict and the three-checkpoint `RENDER_STARTPOS`/`ENDPOS` read-back) brought the file
from 697 to **620 measured lines**, against root `CLAUDE.md`'s ~600-line ceiling. The
seam that entry originally named is gone with the instrumentation; nothing left in the
file is bisectable without cutting load-bearing why.
**The remaining seam is a real responsibility boundary**, and the file header already
names it as two things: `OfflineRenderBackend::capture` (the offline render driver)
versus the four helpers BOTH backends share — `makeUniqueTag`, `captureNameFor`,
`collapseCapturedFileToMono`, `stampCaptureSample` — consumed by `capture_batch`,
`capture_orchestrator`, `capture_realtime_shell`, `capture_realtime_finalize` and
`render_in_place`. Lifting those four into their own TU takes the driver under the
ceiling and gives the cross-backend steps their own home.
**Why not taken.** `src/shell/capture/` has no `CMakeLists.txt` of its own — its sources
are listed in `src/app/CMakeLists.txt`, so a new TU needs an edit there. Forcing the
four helpers into an existing TU instead (orchestrator, realtime finalize) would put
them in a wrong home to dodge one build-file line, which is worse than the overshoot.
## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated)
Idea considered and dropped: read a captured file's `BWF:TimeReference` tag back as
independent evidence on the START-edge millisecond-floor question above. `WDL/metadata.h`'s
`WriteMetadataPrefPos` only writes it past its `prefpos > 0.0` guard (`:1301`) — that guard
alone is enough to rule the approach out. One nuance worth recording separately: the
millisecond quantization at `:1382-1383` (`AddMexMetadata`'s `ParseUInt64(val)/1000.0`)
belongs to the MEX caller, not proven to be `WriteMetadataPrefPos`'s own behavior or the
renderer's direct call into it — so even without the guard, a floored bext tag would show
that MEX quantizes, not that the render engine does.
## Split `render_bounds_gate` on the verdict/message vs. filesystem seam
**Context (Ψ-W3 round-two review).** `render_bounds_gate.cpp` mixes pure verdict
composition (frame-count comparison, message text) with filesystem I/O
(`retainRefusedRender`'s `fs::create_directories`/`fs::rename`) in one shell TU. The
verdict half has no REAPER dependency and no filesystem dependency either — it could be
`core/capture`, unit-tested directly instead of only through the pure `render_window`
functions it calls. The reviewer's suggested split: verdict + message composition pure
and testable in `core/capture`, leaving only `retainRefusedRender` (and the two thin
`checkRendered*` entry points that call it) in `shell/capture`.
**Why deferred.** Out of scope for the dispatch that surfaced it — a structural split,
not the bug fix in front of it.
**Filed also because it's already slightly wrong today.** `render_bounds_gate.cpp`
touches no REAPER API (it is `<filesystem>` + the pure `core/capture` modules only), so
`src/shell/capture/CLAUDE.md`'s "this directory is the REAPER API surface only" scope
line no longer describes it — one more small argument for eventually moving the
REAPER-free half to `core/capture`, separate from the untested-filesystem-code gap
above.
**Done looks like.** `core/capture` owns a pure `checkRenderedBoundsVerdict`-shaped
function under a `<module>_tests` target with no REAPER, no VST3 SDK, and no
filesystem includes; `shell/capture/render_bounds_gate` shrinks to the file-move and
the two callers' plumbing.
## The capture path ignores `saveToActiveProject`'s return at four sites
**Context.** `saveToActiveProject()` returns false for exactly two reasons — no active
project, or an unsaved one — and in both cases NOTHING was written. Four capture sites
discard that return outright: `capture_orchestrator.cpp`'s `RunCapture`, `capture_batch.cpp`'s
`RunBatchCaptureItems` and `RunBatchCaptureRazor`, and `realtime_lifecycle.cpp`'s `CommitRealtimeResult`.
**The wart.** A capture on an unsaved project renders the file into the bank folder, adds
the `Sample` to the in-memory book, records a birth record in memory — and loses all three
on reload. The bytes stay on disk with no index entry and no persisted ledger record, so
they are a foreign file prune will never reclaim (an unrecorded file is untouchable by
design — `core/tracking/CLAUDE.md`). Nothing is printed. The bank-op family already reads
this return and discards its undo point on a false; the capture family does not read it at
all.
**Why filed, not fixed.** Pre-existing, and the right answer is a product decision this
dispatch had no mandate for: refuse the capture up front, keep it and warn, or prompt for
a Save-As (the bank ops chose "quiet persist by design, deliberately no Save-As prompt" —
whether capture should follow is a separate call).
**Done looks like.** A capture attempted with no saved project either does not write bytes
at all, or writes them and says so in a sentence naming what will not survive a reload —
and the choice between those two is recorded rather than implicit.
## `panel_input`'s wheel handler persists the whole book per wheel message
**Context.** `panel_input.cpp``handleWheel` calls `markTailDirty()` on every wheel
message that actually moves `manualMs`, while the pointer is over the footer in Manual
mode. (It coalesces sub-notch deltas within ONE message and no-ops at a bound, so the
count is wheel messages that changed the value, not raw notches.)
**The wart.** `markTailDirty` is `saveToActiveProject()` — a full `BankBook` serialize plus
six ext-state value writes on the UI thread — for a setting that is one number. A flick
over the footer is a dozen of them in a few hundred milliseconds. Disproportionate rather
than incorrect: no guardrail is violated (this is nowhere near the two named hot paths),
and the writes are idempotent.
**Intended fix.** Coalesce: mark dirty and let one timer tick flush, the same shape the
panel already uses elsewhere for repaint batching.
**Done looks like.** A continuous wheel gesture over the footer produces one persist, and
the value that lands is the gesture's final one.
## `RunCaptureItemAssign`'s undo point does not follow the pattern its comment claims
**Context.** `capture_orchestrator.cpp`'s `RunCaptureItemAssign` states that the action follows
the bank-op family's discard-on-unsaved pattern.
**The wart.** It does not: `RunCaptureItemAssign` records the undo point unconditionally whenever
`sampleId` is non-empty, and never consults the persist's return at all. So on an unsaved
project it records an undo point for ext-state that was never written — the empty
no-effect entry `persistBankOp`'s guardrail exists to avoid. The comment describes the
intended behavior, not the code.
**Why filed, not fixed.** It is one instance of the capture-family gap filed above, and
fixing it alone would leave the other four sites divergent. Fix them together, or explain
in one place why capture differs from bank ops.
**Done looks like.** The comment and the code agree, and the whole capture family answers
the unsaved-project case one way.
## `core/tracking/CLAUDE.md`'s untracked-file enumeration says "reaches the `.rpp`" too loosely
**Context.** `src/core/tracking/CLAUDE.md` §"Invariants" — "No silent gaps — in memory at
creation, on disk at the next save" — enumerates how a created file can stay
untracked, and describes the ledger as reaching the `.rpp` at the following
`saveToActiveProject()`.
**The wart.** `saveToActiveProject()` writes REAPER's IN-MEMORY project state and marks the
project dirty; REAPER writes the `.rpp` on the project's own save, which may be much later
or never. The sentence was already loose before this branch and is not made wrong by it —
but it is the same over-claim ("a write reached the file on disk") the bake's reporting
pass spent several rounds removing from its own sentences, so it should read the same way.
**Why filed, not fixed.** Editing another layer's own CLAUDE.md from a persist-and-report
dispatch is exactly the boundary crossing the per-directory docs exist to prevent. It is a
doc-keeper edit.
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
in the `.rpp`", and does not gain a second home for the distinction.
## `view_mode_model.cpp` is over the ~600-line structural bar, and `view.cpp` is close behind
**Context (surfaced by the FX-GUID keying track).** Root `CLAUDE.md`'s structural
heuristics put an ~600-line ceiling on any one file, with a documented responsibility
seam as the required method for splitting it, not an arbitrary bisection.
`src/core/view/view_mode_model.cpp` measures **815 lines** (verified this pass),
up from 715 before the FX-GUID keying track's v2 schema addition made it worse.
**The named seam.** The JSON codec — `serialize()`/`deserialize()` — wants its own
`view_state_codec` TU in `src/core/view/`.
**Why it was deferred, and this reasoning should survive.** `serialize()` is a
`ViewModeModel` member and `deserialize()` a static factory (confirmed:
`std::string ViewModeModel::serialize() const` and
`std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string&)`), both
reaching private state — so extraction needs either a friend declaration or a new
public accessor surface. Doing that in the same commit that changed the byte format
the golden test literals pin would roll a format change and a codec extraction
together, which is the riskier order.
**`src/shell/view/view.cpp` is no longer over the bar.** It measures **594 lines**
(`wc -l`, re-measured after the undo collapse moved one track's whole park/restore —
snapshot, flags and per-FX offline — into `view_fx_park`'s `parkTrack`/
`restoreTrack`). Recorded because the entry above used to name it as a second
over-ceiling file: if it grows again, note that a further seam there is blocked
not by a private-state/friend question but by a build file another team owns —
`src/shell/view/` has no `CMakeLists.txt` of its own today, so a new TU costs one
`target_sources` line in `src/app/CMakeLists.txt` instead.
**Priority / risk.** Not stated.
**Done looks like.** `view_mode_model.cpp`'s JSON codec is extracted into its own
`view_state_codec` TU (with the friend/accessor question resolved deliberately, not
sidestepped), dropping the file under the ~600-line ceiling.
## FX-GUID stability for the park's restore is unverified in the DAW
**Context.** The Design View park/restore FX keying (`restoreTrack`,
`src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
survives a chain reorder while a track is parked. SWS issue #802 reports that after
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
(`SNM_PreObjectState()``RemoveAllIds()`) — if that still holds, an SWS-driven
reorder while parked produces wrong-plugin restores or mass drops, which is the exact
operation this keying targets.
**What must be checked.** Native drag-reorder, an SWS move, save/reload, and two live
instances of the same plugin.
**Already flagged in code — this entry is the tracked home, not a restatement.**
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
them in full.
**Priority / risk.** Not stated.
**Done looks like.** Native reorder, SWS reorder, save/reload, and a
two-instance-of-the-same-plugin case are each observed in a live REAPER session, and
either the identity is confirmed to survive all four, or a degradation is found and
the keying is amended.
+226
View File
@@ -0,0 +1,226 @@
# DAW verification — post-1.0 work on `dev`
Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Build **Release**, install into
`UserPlugins/`, restart REAPER. Panel tail toggle = **None**, project rate 48000, unless a check says otherwise.
## Precision invariants
- [ ] Dry offline item capture of a 2 s range, re-inserted at its source position, inverted against the source — reads silence (`CLAUDE.md` §"Precision invariants" — "Null test")
- [ ] Run the identical offline capture request twice — the two files are byte-identical on disk (`CLAUDE.md` §"Precision invariants" — "Bit-identical repeats")
- [ ] After any capture, source items and tracks are unchanged: fader, pan, mute, FX bypass, selection (`CLAUDE.md` §"Precision invariants" — "Non-destructive")
- [ ] After a realtime capture, the temp track is gone and every source track's routing is back as it was (`CLAUDE.md` §"Precision invariants" — "Non-destructive")
- [ ] Capture 10.00012.000 s — card reads 2.000 s / 96000 frames, no leading or trailing silence (`CLAUDE.md` §"Precision invariants" — "Exact bounds")
- [ ] With an FX on the source track: item scope does NOT carry it, track scope does (`CLAUDE.md` §"Precision invariants" — "Capture FX scope")
- [ ] Track scope on a child track with FX, gain, and pan set on the parent and master — neither colors the capture (`CLAUDE.md` §"Precision invariants" — "Capture FX scope")
- [ ] Save, move the whole project folder elsewhere, reopen — every card still resolves and auditions (`CLAUDE.md` §"Precision invariants" — "Relative paths only")
## The decisive observation
- [ ] **Run first.** `docs/verify-track-scope-multitrack.md` §3 by hand, and count the files REAPER writes (`docs/TODO.md` §"The `&128` multi-track output shape is still DAW-unobserved", `docs/COMPLETED.md` §"Ψ-W3-T1", `docs/COMPLETED.md` §"None of the seven is DAW-verified")
- [ ] Two files confirms Ψ-W1-T1 and Ψ-W3-T1 at once; **one summed file invalidates both** — stop and report, the refusal is costing a capture 1.0.0 accepted (`docs/COMPLETED.md` §"None of the seven is DAW-verified")
- [ ] Then walk the rest of `docs/verify-track-scope-multitrack.md` (§1–§2, §4–§7) for the multi-track refusal itself (`docs/COMPLETED.md` §"Ψ-W3-T1")
## Capture range and bounds
- [ ] Over an item much longer than the selection: item scope × time selection, and item scope × razor — each lands exactly the window, not the whole item (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] **The millisecond floor — SETTLED, nothing to re-run for `TailMode::None`.** The floor lives in the custom-time-bounds field (`RENDER_BOUNDSFLAG=0`), not in the render engine. Two live 48 kHz `TailMode::None` renders on `RENDER_BOUNDSFLAG=2` (time selection, handed over via `GetSet_LoopTimeRange`) came back exact — 97627 frames against 97627 — the second over a window whose START carried a sub-millisecond remainder, with no floored model of that window able to reproduce the count. Time selection is now the only bounds mode a capture can reach; the console verdict line and the `RENDER_STARTPOS`/`ENDPOS` read-back probe that answered this are gone. Full observation: `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`
- [ ] **Still open — Auto and Manual tail.** `checkRenderedBounds` judges `TailMode::None` only (Auto/Manual add frames by design), so the settled result covers those two by INFERENCE, not observation, and the inference rests on an unverified PREMISE too: that the (retired) floor applied to the bounds identically across all three tail modes, and that all three now hand the window over the same way. Neither is measured — both live short renders that settled the bounds mode were `TailMode::None`; no Auto or Manual capture has been observed at all. **On Auto/Manual, the ONLY automatic check left is the 0-byte gate (`checkRenderedFileNotEmpty`)** — there is no automatic bounds signal for those two modes at all until this bullet is closed by hand. What would establish it: repeat an off-grid-start capture at **Manual** over a source that is loud right to the window's end, and check the landed file's frames against window + `tailMs` — a floored edge shows up in that count. **Auto** cannot be checked by count (it trims trailing silence), so it needs the null test by ear/inversion against the source instead
- [ ] `[verify — DAW]` A tail is assumed to render PAST the window end — the SDK header (`:3048`) confirms only that `RENDER_TAILMS` is a length in ms, not that it extends past the end. If that assumption is wrong, a tail capture is silently SHORTER than its window with no detector at all. Report whether either tail capture comes up short against the source
- [ ] A refused render is kept for diagnosis at `<project folder>/reasampler_refused/` (the refusal line names the path; a failed move leaves it unindexed in the bank folder and says so). Delete the folder when done — nothing in the bank references it
- [ ] **If a capture is refused for a short render**, report the refusal line verbatim. A message naming `floored to the millisecond` means the floor is back on a mode measured escaping it; a shortfall of one or two frames with no such sentence may be the gate's own edge-convention tolerance rather than the render (`render_window.h`'s `renderHonoredBounds`)
## Names and channels
- [ ] Capture from a named track — the card reads `<Track> MM-DD HHMM`; capture again the same minute and the second carries an ordinal (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] Capture from an unnamed track, and from a multi-item selection — both readable, `+N` present on the multi (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] Load a named capture into ReaSampler 9000 — the same name shows there (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] The card label stays legible over its scrim at every card size (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] Capture a dead-center mono source — the `.wav` is roughly half the size of the equivalent stereo capture (`docs/COMPLETED.md` §"Ψ-W2-T2")
- [ ] Insert that collapsed file on a stereo track and null it against the source — confirms REAPER sums a 1-channel item at unity (`docs/COMPLETED.md` §"Ψ-W2-T2")
- [ ] Capture a true-stereo source — stays 2-channel, and both it and the collapsed file load into the instrument correctly (`docs/COMPLETED.md` §"Ψ-W2-T2")
## Mode switching
- [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] `[verify — DAW]` Reproduce the strand: project with FX in both modes, switch to Design, save, close, reopen, toggle to Arrange. Toggle back and forth several more times — the arrangement's FX must NOT converge on permanently offline/hidden. A track the tool refuses to park is NAMED in the console, and the same refusal is not reprinted on a later reapply unless the refused set changed (`src/shell/view/view_fx_park.h`'s `decidePark` / `reportRefusedParks`)
- [ ] `[verify — DAW]` **ONE switch, ONE point.** In a project with several FX across several tracks, switch modes once and count the undo points REAPER shows (Ctrl-Z tooltip / undo history): expect exactly ONE, `ReaSampler: activate <mode> view` — never two, and never one per FX. Then press Ctrl-Z once: the flags unpark and EVERY FX comes back online, in that single step. Verify the EFFECT, not merely that one entry exists — a mask that failed to pick up `UNDO_STATE_FX` would still show one correct-looking entry while leaving every plugin offline, which reads as a pass and isn't. Then Ctrl-Y: the redo re-parks all of it, also in one step (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point")
- [ ] `[verify — DAW]` **Where the `view_state` lands on undo AND redo, and whether the redo strands a track.** `persistViewState` writes the ext state AFTER `applyMode`'s block closes, so the point never saw the new `view_state` — the undo direction is expected to come out right by position, the redo direction is the open one. Use a project with FX on the tracks that park. After the Ctrl-Z above, read the footer segment: it must show the mode the user came FROM, and a further switch must bank solos under that mode id. Then Ctrl-Y and read it again: if the tracks re-park while the footer still shows the pre-switch mode, the redo restored the stale ext state and model-vs-project is out of step. **Then switch modes ONCE MORE and read the console** — the hypothesised consequence is a `left unparked` refusal line naming those tracks, because the redo-restored model carries no snapshot for a chain that reads parked. A clean switch with no refusal line kills the hypothesis; the line appearing confirms a NEW refusal route and the persist position has to be reopened. Report which, and the footer state at each step. Do NOT fix by moving `persistViewState` inside the block — it can open a modal Save-As (`src/shell/view/CLAUDE.md` §Gotchas)
- [ ] `[verify — DAW]` **No point on open.** Open a project saved in Design mode with every inactive leaf already fully parked (flags AND FX), and check the undo history: the load-tick reapply writes nothing, so it must add NO undo point at all — the `("", 0)` discard form. This only holds when the saved state really was fully parked; a first open after tagging a new leaf, an FX added to a parked track, or a plugin hand-onlined since the last save all have real state to write and legitimately mint one correct point. An entry appearing on a project that WAS fully parked means the discard is not working — and since `mintManagedLanes`' no-op path rests on the same idiom, check that alongside it (`src/shell/view/view.h`'s `applyMintsUndoPoint`)
- [ ] `[verify — DAW]` **The hitch now sits on the switch.** On a project with heavy plugins (convolution, a loaded sampler) across several parked tracks, time the mode toggle: by design the action does not return until every plugin has unloaded/reloaded. Report roughly how long, and whether the UI recovers cleanly afterwards — the whole write phase runs under one `PreventUIRefresh(1)` hold (`src/shell/view/CLAUDE.md` §Invariants — the documented caveat)
- [ ] `[verify — DAW]` **Nested blocks around the FX writes.** Two directions, both undocumented in the SDK header. (a) `render_in_place` calls `applyMode` inside its OWN undo block, so its single `ReaSampler: render selected track to a new track` point should now absorb the reapply's FX moves — run that verb from Design mode over a track with FX and confirm ONE point, not two, and that Ctrl-Z reverses all of it. (b) The inverse: fire a mode-switch action WHILE a switch's plugin loads are in flight (a heavy chain widens the window) — `applyMode` is latched, so the expected outcome is that the second switch is DROPPED entirely and silently; the history must show one point, and the footer must still read the first switch's target. Re-firing it afterwards must work normally. (c) The same window, but let `OnTimer` -> `bankPanelRefresh` -> `mintManagedLanes` run into it: that path is NOT latched and opens a block of its own, so check the history for a split or misordered point (`src/shell/view/CLAUDE.md` §Gotchas)
- [ ] `[verify — DAW]` **The load reapply is never lost to the latch.** Open a project saved in Design mode with heavy plugins on parked tracks (so an apply is long) while another apply can overlap — e.g. trigger a project open from an action fired during a switch. The saved mode must still be applied: the load glue tests `modeApplyInProgress` before spending its one-shot signal and retries on the next timer tick, so a delayed-by-a-tick reapply is a pass and a never-applied one is a failure (`src/app/main.cpp`'s load glue, `src/shell/view/view.h`'s `modeApplyInProgress`)
- [ ] `[verify — DAW]` **A tab switch mid-apply cannot split the block.** `applyMode` pins the project once at entry and threads it through `Undo_BeginBlock2`/`Undo_EndBlock2`/`ValidatePtr2`. With a long switch in flight, switch project tabs (the pump premise makes this reachable) and confirm the undo point lands on the project the switch started in, with nothing written into the other tab (`src/shell/view/view.cpp`'s `applyMode`)
- [ ] `[verify — DAW]` **A→B→A costs two full cycles.** Toggle Arrange→Design→Arrange quickly on a project with heavy plugins: every plugin unloads and reloads twice, where the retired intent queue made a fast double-toggle free. Confirm the END STATE is still correct (every FX online, every driven flag back at its captured value) and report whether the doubled cost is tolerable in practice (`src/shell/view/CLAUDE.md` §Invariants — the A→B→A bullet)
- [ ] `[verify — DAW]` For a track the console names as left unparked, follow the message's recovery IN FULL: re-enable its TCP/mixer visibility, main send and FX enable, **and set every FX in its chain online** (FX enable is the chain bypass — it does not bring an individually offlined FX back). Then switch modes twice: it parks and restores normally, with no further console line. Do the flag half ONLY on a second such track and confirm it is still refused — the FX half of the trust test, and the reason the message spells the step out (unit-tested only as a decision, never against a live chain)
## Actions and drops
- [ ] Add the import action to a Media Explorer toolbar and fire it from there — it imports (`docs/COMPLETED.md` §"Ψ-W1-T3")
- [ ] Fire the existing Main-section import binding — still works (`docs/COMPLETED.md` §"Ψ-W1-T3")
- [ ] Unload/reload (restart REAPER) — no duplicate Media Explorer entry in the action list (`docs/COMPLETED.md` §"Ψ-W1-T3")
- [ ] Drag one card across the arrange, over an FX window, over the TCP/MCP and back — cue changes per surface, every transition reverses (`docs/COMPLETED.md` §"Ψ-W1-T4")
- [ ] Drag fast, and drag onto a narrow TCP — target class still resolves; no release anywhere in REAPER is a silent no-op (`docs/COMPLETED.md` §"Ψ-W1-T4")
- [ ] Drop a single card into the arrange — an item lands at the pointer's track and time (`docs/COMPLETED.md` §"Ψ-W1-T4")
- [ ] Drag-out to an external app twenty-plus times in a row — audio arrives every time; this is a soak, a single pass is not a gate (`docs/COMPLETED.md` §"Θ-W1-T2" — "Neither acceptance criterion has actually been met yet")
- [ ] Drop a capture onto an FX container — the instrument loads with that capture (`docs/COMPLETED.md` §"Θ-W1-T2" — "Neither acceptance criterion has actually been met yet")
## Bank packages
- [ ] **Run in full.** `docs/verify-package-transfer.md` — the whole cross-machine
export/import round trip: writes-one-file, the transfer itself, re-importing the
same file never overwrites, the round trip back to the source, the too-new /
truncated / mid-payload-corruption refusals (each an exact string), the
unsaved-project refusals, and drag-and-drop (`docs/COMPLETED.md` §"Ε-W3-T1")
- [ ] Force a degraded tracking ledger and confirm the import refuses **before the
file picker opens**: save a project with a bank, close REAPER, edit the saved
`.rpp`'s `owned_files` ext-state value inside its `<REASAMPLER ...>` block — corrupt
the JSON for the `Unreadable` case, or bump `"v":2` to `"v":3` for the
`FutureVersion` case — reopen the project, then run *ReaSampler: import bank
package (.rsbank)*. Read off: the console prints the ledger-refusal block and no
file dialog ever appears (`origin_ledger.h`'s `LedgerStatus` and `ledgerDegraded`,
`package_import_action.cpp`'s `ledgerPermits`)
- [ ] Export dialog: type a destination name with no extension, then again over a
name that already carries a different one (e.g. `mybank.bak`) — read off whether
`GetUserFileName` appended `.rsbank` itself or ReaSampler's own re-append produced
the double-extension result (`mybank.bak.rsbank`) the code expects
(`src/shell/package/CLAUDE.md` §"Gotchas" — "The re-append is suffix-blind")
- [ ] Both the export and the import file dialogs open in front of REAPER's main
window, not behind it — `GetUserFileName` takes no owner window
(`src/shell/package/CLAUDE.md` §"Gotchas" — "`GetUserFileName` also takes no owner window")
- [ ] With a ReaSampler 9000 instance's editor open on the destination project
(Browse view visible), import a `.rsbank` from the docked panel — the browser
reflects the new bank without closing or reopening the editor (the bank-generation
bump, `session.h`'s `bumpBankGeneration`, polled by the instrument at
`processor_reload.cpp`'s `pollBankSync`)
- [ ] Drag two or more `.rsbank` files onto the docked panel in one drop — each lands
as its OWN new bank, never merged into one, and if the tracking ledger is degraded
the refusal prints ONCE for the whole drop rather than once per file
(`panel_window.cpp`'s `handleDropFiles`)
- [ ] Kill REAPER (or the process) partway through an import so a partial bank file
is stranded under its real name in the bank folder, then re-run the same import
into the same project — read off what happens. Whether the import verb should
pre-clean that stale debris is an open question, not yet decided
(`src/shell/package/CLAUDE.md` §"Gotchas" — "A crash mid-export strands the `.rsbanktmp` sibling")
## The resample bake
- [ ] Bake a dialed sound — the banked file sounds like what the editor was playing (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Bake the result twice more — iteration composes, nothing is lost per pass (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Save and reopen after a bake — the instance still points at the baked capture (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Confirm no bake put an item in the arrange, and the superseded file is still on disk (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Bake from an instance in a background project tab — refuses rather than writing into the wrong bank (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Two behaviors worth recording")
- [ ] Load the VST with the extension not installed — the resample affordance reads unavailable, not silently lossy (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Two behaviors worth recording")
- [ ] After a bake: instance is in Trigger with start point reset, channel mode and preview velocity survived (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Reset-scope classifications made at review")
- [ ] Gate mode + active sustain loop — "Bake Hold" appears within ~500 ms, its label fits its cell, its travel is duration-ordered (`docs/COMPLETED.md` §"Ξ-W3-T1")
## Render in place
- [ ] Fire *Render track in place* over a track with a range selected — solo the source
and the new sibling track, invert one track's polarity, and confirm silence. This is
Ρ's own trust anchor: the placement is the null test performed automatically
(`docs/product/render-in-place.md` §"DAW-verification obligations",
`docs/COMPLETED.md` §"Phase Ρ — Render in place")
- [ ] Render in place from three source positions in turn — a normal mid-folder track, a
track that is last in its folder, and a folder-parent track — each time confirm the new
sibling track lands at the same nesting level as the source and that the folder bus
feeds (or bypasses) it correctly. Then, to settle whether `TrackList_AdjustWindows(false)`
is actually needed: comment out that call in `render_in_place.cpp`'s
`RunRenderTrackInPlace` (it was added preemptively, answering a question the spec had
left open rather than one the code confirmed), rebuild, and repeat the folder-parent
case — if nesting still displays correctly with the call removed, it can be dropped in a
follow-up (`docs/product/render-in-place.md` §"DAW-verification obligations")
- [ ] Render in place from a dead-centre (channel-identical) source — confirm the placed
item is mono, and confirm it plays back at the same perceived level the stereo source
did before the render. This is root `CLAUDE.md`'s existing mono-summing
`[verify — DAW]`, promoted to load-bearing because Ρ is the first path that places a
collapsed render into the mix automatically (`docs/product/render-in-place.md`
§"DAW-verification obligations")
- [ ] Fire Render track in place once from Arrange and once from Design. From Arrange:
confirm the source track parks and the result track is visible and in the mix. From
Design: confirm the source stays on the bench, the result track is parked too, then
switch to Arrange and confirm the result track appears in the source's place. **In
both cases wait out at least one panel timer tick before checking membership** — that
is the check that catches a missing explicit-tag-wins filter or an untagged item,
either of which silently reverses the ruling that the result track is always an
Arrange member (`docs/product/render-in-place.md` §"Mode transitions — the source
parks, the result goes to Arrange")
- [ ] Render in place, then press Ctrl-Z once — confirm the new track and its item are
both gone, the source track's folder depth is restored, the rendered file itself is
still on disk, and the source track is still tagged Design (`docs/product/render-in-place.md`
§"DAW-verification obligations")
- [ ] Render in place from a named source, then run it again over the resulting (already
`Capture `-prefixed) track — confirm the name does not stack a second prefix, and
confirm the new sibling's colour matches the source's (the colour clone has no unit
coverage at all). Repeat once from an unnamed source and confirm the result reads
`Capture Track N` (`docs/product/render-in-place.md` §"DAW-verification obligations";
the name-composition logic itself — apart from the live colour clone and the real
`GetTrackName`/`P_NAME` round trip — is unit-tested in `tests/test_capture_name.cpp`)
- [ ] Save a project into a folder whose recording path is set away from the default
(Project Settings → Media → Path), then Render track in place — confirm the rendered
file lands in that configured recording path, not the project folder itself
(`render_in_place.cpp`'s `RunRenderTrackInPlace`, `GetProjectPathEx`)
- [ ] Render in place, save the project, and reopen it — confirm the result track (which
carries an explicit `kArrangeModeId` membership record, unlike the shipped
tag-selected-tracks action which never writes one) behaves identically, in every
mode-switch and visibility check, to an ordinary untagged Arrange track. The JSON
round-trip itself is unit-tested (`tests/test_view_mode_model.cpp`); this is the
live-view half that isn't (`docs/product/render-in-place.md` §"Mode transitions — the
source parks, the result goes to Arrange")
## Instrument migration
- [ ] Open a project saved before the zone retirement — the instance reopens on its first zone and sounds the same (`docs/COMPLETED.md` §"Θ-W1-T1")
- [ ] Such an instance with implicit channel mode + a stereo capture reopens **Stereo** — confirm that is acceptable by ear (`docs/COMPLETED.md` §"Θ-W1-T1")
## Waveform marker zero-crossing snap (Ω-W2-T5)
- [ ] `[verify — DAW]` Drag a Gate loop mark on a single-cycle capture — the felt radius the mark
may be pulled to reach a crossing genuinely reads as imperceptible, i.e. a displacement inside
the pointer's own grab neighbourhood is not one the eye catches (`src/core/instrument/ui/waveform_view.h`'s
`kZeroCrossingSnapPx` comment)
- [ ] `[verify — DAW]` Hold Ctrl, then drag a waveform marker (start/loop start/loop end) off a
zero crossing — confirm it lands exactly where dropped, with no snap, in a live editor window
(`editor_input_waveform.cpp`'s `dragWaveform`, `GetKeyState`-driven, no CI test target)
- [ ] `[verify — DAW]` Press and hold Ctrl BEFORE mouse-down on a mark that coincides with a
contour node or another mark's cap — confirm the mark is grabbed (not the node dragged, not a
hard/smooth toggle fired), matching the fix in `resolveWaveformClaim`
(`src/core/instrument/ui/spline_edit.cpp`)
## Look and feel
- [ ] Sign off by eye in a live editor window: knob arcs, needles, envelope splines, waveform outline (`docs/COMPLETED.md` §"Θ-W7-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Antialiasing pass")
- [ ] Same pass for legibility: text sizes, arc weight, and whether the waveform stroke thickens the docked panel (`docs/COMPLETED.md` §"Θ-W6-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Sizing")
- [ ] Piano strip at the 840 px default — keys tile uniformly, the 37 px end gutters read as acceptable (`docs/COMPLETED.md` §"Θ-W2-T3")
- [ ] Resize the editor across several widths — gutters stay symmetric, no key width jumps (`docs/COMPLETED.md` §"Θ-W2-T3")
- [ ] Set host/OS scaling to 150% then 200% — record how the strip and the AA strokes actually look (`docs/TODO.md` §"High-DPI host scaling is unverified (distinct from the antialiasing audit)", `docs/COMPLETED.md` §"Θ-W2-T3" — "Width uniformity is guaranteed in client pixels only")
## The VST3 parameter surface
- [ ] `[verify — DAW]` Whether REAPER renders `ParameterInfo::units` beside the string
`getParamStringByValue` returns, or shows the string alone. We ship the SDK's own
convention (digits in the string, unit carried separately); if REAPER shows no unit at
all, the fallback is a one-line change to the single formatter, touching neither the
frozen id table nor the editor (`docs/COMPLETED.md` §"Γ-W4-T1")
- [ ] Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping`
CC table would have — the decision to ship no default map rests on it; if learn does not
reach these parameters, a CC table is additive and frozen by nothing (`docs/COMPLETED.md`
§"Γ-W4-T1")
- [ ] The three migration round trips: a pre-parameter project opens with every parameter
reading the blob's value and sounds identical; a project saved by this build restores
fully in an older binary; a project with automation drawn, saved and reopened, replays
against the same plain values (`docs/COMPLETED.md` §"Γ-W4-T1")
- [ ] `[verify — DAW]` **Whether an offline render replays automation at all** — the
sharpest case for the audio-side drain, because the host drives `process()` and may
never touch the controller (`docs/COMPLETED.md` §"Γ-W4-T1")
- [ ] `[verify — DAW]` **Whether REAPER restores instance state through `setState`, not
`setComponentState`** — the entry-point half of the original bundled `[verify, FIRST]`
that an earlier pass closed only the delivery-channel half of. The evidence short of a
DAW is strong but is inference: `vstsinglecomponenteffect.h:41-47` collapses the two
names on a single-component plug-in, and the blob has round-tripped through payload
v1…v16 in real projects. Exercising it costs one save/reopen (`docs/COMPLETED.md`
§"Γ-W4-T1")
@@ -0,0 +1,813 @@
# Λ-W1 Track 1 — Linux build/toolchain audit (build-system lens)
Static analysis of the build surface only — the 23 `CMakeLists.txt` files, `cmake/`,
`src/resource.rc`/`src/resource.h` + the SWELL resgen pipeline, the three vendored submodules
as *build inputs*, the `ctest` harness, toolchain-level flags, and the packaging story in
`docs/product/versioning-and-release.md`. 2026-08-02, branch `pl-w1-t1-build-toolchain-audit`
(cut from `dev` at 6e937b9).
**Source portability is explicitly NOT audited here** — no Win32/GDI/LICE API usage, path
handling, threading, drag/drop, dialog code, fonts, or instrument runtime code. A parallel
track (T2) owns that; what I noticed in passing is listed under "Referred to T2" at the end
and nowhere else.
**Verification posture.** I am on Windows and cannot build or run anything on Linux. Every
claim below is grounded in a file I opened in this worktree — the tree's own CMake, or a
vendored header/source under `vendor/` — not asserted from memory of similar SDKs. Where a
claim depends on runtime behaviour I cannot observe (does REAPER's Linux build call
`SWELL_dllMain`? does GCC accept this TU?), it is marked `[verify — Linux]` with the exact
check, and it is **not** stated as fact. A subsequent citation pass caught several
line-number and attribution errors in this draft (corrected in place, disclosed at the point
each occurs) — grounded-in-a-file is a floor, not a guarantee against a mis-copied line range.
To get the vendored citations I ran `git submodule update --init` for all three submodules
inside this worktree; nothing in the tracked tree was modified.
Severity vocabulary, per the dispatch: **Blocker** = the Linux build cannot produce a loadable
artifact; **Major** = it builds but is degraded or unshippable; **Minor** = ergonomics,
warnings, CI polish. Effort bands are S (< half a day), M (13 days), L (a week+), and each
names the reason for the band rather than asserting a number.
---
## 1. Baseline — what the build does today, per platform
**Two loadable modules, 84 pure libraries declared under `src/core/`, 91 CTest targets,
three vendored submodules.** (84 = the `reasampler_pure_library` + bare `add_library` count,
grep-verified library-by-library across the 18 `src/core/**` CMake files that declare any —
`src/core/CMakeLists.txt` and `src/core/instrument/CMakeLists.txt` declare none, only
`add_subdirectory` calls (plus one test in the latter); there are 20 CMake files under
`src/core/**` total, matching the 19 figure in §1d below, which counts *test* declarations
across the whole tree, not `src/core/**` alone. 46 of the 84 are on the extension's link
line.)
- Root `CMakeLists.txt` is 95 lines and declares **no targets at all**: it owns the version
string (`:24`), the channel fork (`:4054`), the `configure_file` for
`version_generated.h` (`:5861`), the four vendor path variables (`:6568`), the
four-TU `LICE_SRC` list (`:7883`), `enable_testing()` (`:90`), and three
`add_subdirectory` calls (`:9294`).
- Language/toolchain settings are exactly three lines: `CMAKE_CXX_STANDARD 17`,
`CMAKE_CXX_STANDARD_REQUIRED ON`, `CMAKE_POSITION_INDEPENDENT_CODE ON`
(`CMakeLists.txt:2830`). There is **no** `CMAKE_BUILD_TYPE`, no `CMAKE_CXX_FLAGS`, no
`CMAKE_CXX_EXTENSIONS`, no IPO/LTO setting, and no `target_compile_options` anywhere in
the tree — I grepped all 23 CMake files for `compile_options`, `find_package`,
`pkg_check`, `link_directories`, `target_link_options`, and `install(` and got zero hits
outside the two files named below.
- **Platform conditionals exist in exactly two files.** Grepping all 23 `CMakeLists.txt`
for `WIN32|APPLE|UNIX|LINUX|MSVC|GNU|Clang|CMAKE_SYSTEM` returns hits only in
`src/app/CMakeLists.txt` (`:71` `if(WIN32)`, `:76` `elseif(APPLE)`, `:88` `else()`) and
`src/shell/instrument/CMakeLists.txt` (`:9` `if(WIN32 AND EXISTS ...)`). Every other
CMake file is platform-neutral by construction: they call the two helpers in
`cmake/reasampler_targets.cmake` (`reasampler_pure_library` at `:815`,
`reasampler_test` at `:1924`) and nothing else.
### 1a. The REAPER extension (`reaper_reasampler`)
Declared as a `MODULE` library over 39 listed TUs (38 shell TUs + `app/main.cpp` itself)
plus `${LICE_SRC}` (`src/app/CMakeLists.txt:851`), linking 46 pure static libraries (`:52`) with
`${SDK_INC} ${WDL_INC}` on the include path (`:56`). Output naming is
`PREFIX ""` + channel-derived `OUTPUT_NAME`, pinned to the top of the build tree by
`LIBRARY_OUTPUT_DIRECTORY`/`ARCHIVE_OUTPUT_DIRECTORY` (`:6569`).
The three-way platform block (`:7198`) is:
| Branch | State | What it does |
|---|---|---|
| `if(WIN32)` `:7174` | **Live and shipped** | adds `src/resource.rc` for the RC compiler |
| `elseif(APPLE)` `:7686` | **Partly aspirational** | adds `swell-modstub.mm`, defines `SWELL_PROVIDED_BY_APP`, links `-framework AppKit`, forces `.dylib`. The resgen `target_sources` line at `:86` is **commented out**. |
| `else()` (Linux) `:8897` | **Partly aspirational** | adds `swell-modstub-generic.cpp`, defines `SWELL_PROVIDED_BY_APP`, forces `.so`. The resgen `target_sources` line at `:97` is **commented out**. |
So on Linux the build system today produces `reaper_reasampler.so` with the SWELL modstub
compiled in and **no dialog resource of any kind**. `README.md:6768` states this is
"expected, not a bug" — the tree is honest about it.
**Open question, macOS-only, unresolved by this doc.** The APPLE branch adds
`${SWELL}/swell-modstub.mm` (`:79`), an Objective-C++ TU, but `CMakeLists.txt:26` is
`project(reaper_reasampler VERSION 1.4.0 LANGUAGES CXX)` — OBJCXX is never enabled anywhere
in the tree. Whether CMake resolves a `.mm` source against a plain CXX-only project (some
Apple-toolchain configurations infer Objective-C++ from the extension regardless; others do
not) is unverified from this tree and untestable without a macOS box. `[verify — macOS]`.
Three build-input facts I verified in `vendor/` and that hold in the extension's favour:
- `vendor/WDL/WDL/swell/swell-modstub-generic.cpp` is entirely inside
`#ifdef SWELL_PROVIDED_BY_APP` (`:21`, closed at `:150`). Since
`SWELL_LOAD_SWELL_DYLIB` is not defined anywhere in this build, the `#else` branch at
`:133` applies: the file's whole contribution is the three
`SWELL_curmodule_*resource_head` globals (`:3133`), the API function-pointer table
built from `swell.h` (`:3749`), and the `SWELL_dllMain` export (`:135`) that resolves
every SWELL symbol from a host-supplied `GetFunc`. That is the correct stub for a
REAPER extension, and the comment at `src/app/CMakeLists.txt:90` ("With
SWELL_PROVIDED_BY_APP you can drop pkg-config / -lX11 entirely") is **correct**: no SWELL
implementation TU, no X11, no GDK is compiled or linked into the extension.
- The four-TU LICE slice is self-contained on Linux. `lice.cpp` reaches SWELL only through
`SWELL_DeleteGfxContext` (`:153`, `:218`), `SWELL_GetCtxFrameBuffer` (`:182`, `:226`) and
`SWELL_CreateMemContext` (`:224`); `lice_textnew.cpp` reaches it through
`SWELL_PushClipRegion`/`SWELL_SetClipRegion` (`:10271028`) and `SWELL_PopClipRegion`
(`:1048`). All six are entries in SWELL's API table
(`vendor/WDL/WDL/swell/swell-functions.h:857`, `:863`, `:877`, `:885887`), so the
modstub resolves them from the host. No fifth LICE TU is implied: the `_LICE_RGB2HSV_invtab`
table lives inside `lice.cpp` itself (`:3040`), and the one non-Win32-only helper
`utf8makechar` is a file-static defined at `lice_textnew.cpp:26` and used at `:241242`.
This is a link-success inference, not a from-first-principles proof — the actual proof is
V1, a successful build. One loose end: SWELL's own `Makefile:106107` lists
`lice_colorspace.o` among the LICE objects *its* build needs — a fifth TU we don't compile.
It stays unreconciled but harmless: `lice_colorspace.cpp` defines the real `LICE_RGB2HSV`
function (declared `lice.h:577`, distinct from the `_invtab` table above), and grepping our
four LICE_SRC TUs and all of `src/` for a call to it returns nothing — SWELL needs the TU
for its own generic-GDK backend, which our four-TU slice never invokes into.
- `CMAKE_POSITION_INDEPENDENT_CODE ON` (`CMakeLists.txt:30`) is already set. This is the
single most commonly missed setting for a `MODULE` target fed by `STATIC` libraries on
Linux, and it is correct today.
### 1b. The VST3 instrument (`reasampler_vst`)
Hard-gated to Windows: `if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")`
(`src/shell/instrument/CMakeLists.txt:9`). The gate is a conjunction — even with the
`vendor/vst3sdk` slice fully initialised, a Linux configure silently omits the target.
`README.md:3335` states this explicitly. Two module-level `CLAUDE.md` files record the
underlying product decision (D5) as *settled*, with "a mac/Linux build" named as a
non-goal: `src/core/instrument/CLAUDE.md` (Non-goals/guardrails) and
`src/shell/instrument/CLAUDE.md` (same). `src/shell/panel/CLAUDE.md` carries a third
instance ("Windows-only (D5) — font/GDI/HFONT choices assume Windows").
Inside the gate: a 20-source `vst3_sdk` static library (`:1638`, grep-counted; lines 28/29/37
are comments) pinned to SDK tag
`v3.7.9_build_61` (confirmed by `git describe --tags` in `vendor/vst3sdk`), and the
`reasampler_vst` MODULE (`:4481`) which additionally compiles `dllmain.cpp` and
`moduleinit.cpp` directly (`:7879`, with the linker-stripping rationale at `:7577`) plus
`${LICE_SRC}` (`:80`). Output is a single **file** named `<name>.vst3` (`:106111`).
### 1c. Resources
`src/resource.rc` declares exactly one dialog — `IDD_BANK_PANEL`, a bare `WS_CHILD`
`DIALOG` with an empty `BEGIN`/`END` body and no controls (`:1822`). `src/resource.h`
defines the single id `IDD_BANK_PANEL 1000` (`:8`). It is consumed by
`CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …)` at
`src/shell/panel/panel_window.cpp:135`.
### 1d. Test harness
91 test declarations across 19 `CMakeLists.txt` files (grep count), matching 91 files in
`tests/`. 90 go through `reasampler_test` (`cmake/reasampler_targets.cmake:1924`, which is
`add_executable` + `target_link_libraries` + `add_test`); one is longhand —
`app_version_padding_tests` (`src/core/version/CMakeLists.txt:3742`), which recompiles
`app_version.cpp` against a second `configure_file` output so the include-dir substitution
works. Neither shape carries anything platform-specific.
Only two test files mention a platform at all: `tests/test_capture_paths.cpp` (five
`#ifdef _WIN32` blocks at `:19`, `:41`, `:87`, `:152`, `:205`, each with a POSIX `#else`
branch already written — e.g. `:158168` asserts `resolveBankFile("/home/d/proj", …)`), and
`tests/test_pitch_shift.cpp:3`, where the string is inside a comment explaining why the
target links nothing that would drag `<windows.h>` in. No test includes `reaper_plugin.h`,
`windows.h`, or any SWELL header. **The test harness is the healthiest part of this
surface**; see §3 for the one generator-shape caveat.
### 1e. Packaging / release, as already committed
`docs/product/versioning-and-release.md` already commits to Linux as a first-class target:
- `:361363` — "**Artifact per platform.** The binary is `reaper_*.dll|.dylib|.so`; the
macOS/Linux builds need the SWELL resgen step (CLAUDE.md §SWELL dialog resources) baked
into the pipeline. Three platform artifacts per channel per release."
- `:307310` — two named artifacts (stable + beta) per platform, "three platform artifacts
each", explicitly handed off to dev-ops.
- `:364366` — "Install is copy-in + restart… there is no server-side rollout."
What that document does **not** contain: any mention of code signing, macOS notarization, a
CI runner, or a Linux install path. I grepped it for `sign|notariz|CI|platform|linux` — one
hit is not cited above (`:393`, "artifacts (stable + beta) per platform", the same dev-ops
handoff as `:307310`); the rest are the lines above. The substantive conclusion — no
signing/notarization/CI/Linux-install-path mention anywhere — holds regardless. There is no
CI configuration anywhere in the repo (`ls -a` on
the root shows `.git`, `.gitignore`, `.gitmodules`, `CLAUDE.md`, `CMakeLists.txt`, `LICENSE`,
`README.md`, `cmake`, `docs`, `src`, `tests`, `vendor` — no `.github`, no `.gitlab-ci.yml`),
and no `install()` rule in any of the 23 CMake files. So "the pipeline" is entirely notional
today, on every platform.
---
## 2. Findings
### Λ-01 — the Linux/macOS dialog-resource path is broken in three independent ways, and the fix may not need resgen at all
**Severity: Major. Effort: S (mechanical) to M (if the resgen route is taken and automated).**
**Citations.** `src/app/CMakeLists.txt:9497` (the Linux comment block) and `:8386` (the
identical macOS one); `vendor/WDL/WDL/swell/swell_resgen.php:313314`;
`vendor/WDL/WDL/swell/swell-dlggen.h:22`; `vendor/WDL/WDL/swell/sample_project/myapp.cpp:192193`;
`vendor/WDL/WDL/swell/swell-dlggen.h:214218`; `vendor/WDL/WDL/swell/swell-modstub-generic.cpp:3133`;
`vendor/WDL/WDL/swell/swell-functions.h:612619`; `src/resource.rc:1822`;
`src/shell/panel/panel_window.cpp:135`.
**What breaks.** Three defects stack, and each alone would sink the step:
1. **The named script does not exist.** Both comment blocks say
`php ${WDL_INC}/swell/mac_resgen.php src/resource.rc` (`:85` and `:96`). Listing
`vendor/WDL/WDL/swell/` shows `swell_resgen.php`, `swell_resgen.pl`, and
`swell_resgen.sh` — there is no `mac_resgen.php`. `CLAUDE.md` and `README.md:120` both
name the correct script; only the CMake comments are wrong, which is exactly the file an
implementer will be reading when they do the work.
2. **The named output file is not what the generator writes.** Both commented
`target_sources` lines (`:86`, `:97`) reference
`${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h`. `swell_resgen.php:313314` composes its
output names as `$srcfn . "_mac_menu"` and `$srcfn . "_mac_dlg"` — so the actual outputs
are `src/resource.rc_mac_dlg` and `src/resource.rc_mac_menu`, with **no** `.h` suffix.
3. **Even the correct filename cannot be a `target_sources` entry.** `swell-dlggen.h:22`
states the contract outright: "Use swell_resgen.pl or swell_resgen.php on the .rc file,
which will produce .rc_mac_dlg, **include that**." The generated body is a run of
`SWELL_DEFINE_DIALOG_RESOURCE_BEGIN(...)` macro invocations
(`swell_resgen.php:117129`) that only expand after `swell-dlggen.h` has been included —
`swell-dlggen.h:214218` shows the macro emitting a static `SWELL_DialogRegHelper` that
registers into `SWELL_curmodule_dialogresource_head`. The canonical consumption pattern
is the SDK's own sample: `sample_project/myapp.cpp:192193` does
`#include "../WDL/swell/swell-dlggen.h"` then `#include "res.rc_mac_dlg"`. A CMake
`target_sources` entry for an extensionless file would not even get a language assigned.
**The runtime consequence, and why this is Major rather than Blocker.** The `.so` still
builds and still loads: the extension's whole action family, capture pipeline, and persist
layer are untouched. What fails is the dock panel. `swell-functions.h:615` defines
`CreateDialog(hinst,resid,par,dlgproc)` as
`SWELL_CreateDialog(SWELL_curmodule_dialogresource_head,(resid),par,dlgproc,0)`.
`swell-modstub-generic.cpp:32` *defines* that head but nothing ever populates it, because
the resgen'd registration TU does not exist — so it stays null and
`panel_window.cpp:135`'s `CreateDialogParam` gets a null resource list. The panel is the
product's primary surface, so a Linux build without it is not shippable; it is not a
Blocker only because the binary loads and the actions work.
**The route worth considering first.** `swell-functions.h:606608` documents an escape
hatch: *"If you are using CreateDialog() and creating a child window, you can use a resource
ID of 0, which creates an opaque child window. Instead of passing a DLGPROC, you should pass
a (WNDPROC) routine that returns LRESULT (and cast it to DLGPROC)."* The header comment is
not just documentation — the implementation confirms both halves of it:
`swell-dlg-generic.cpp:291292` is `SWELL_DialogResourceIndex *p=resById(reshead,resid); if
(!p&&resid) return 0;`, and `resById` (`:3443`) walks from a (here, null) `reshead` and
returns 0 on a miss. That confirms the graceful-failure claim behind "Major rather than
Blocker" above (a null `reshead` plus a non-zero `resid` returns 0, not a crash — matching
`panel_window.cpp:137`'s `if (!g_panel.hwnd) return;`), **and** that `resid == 0` skips the
lookup entirely (`&&resid` short-circuits), which is the escape hatch this paragraph is about.
`src/resource.rc:1822`
shows `IDD_BANK_PANEL` is precisely that: a `WS_CHILD` dialog with an empty body and zero
controls, whose comment at `:810` says "the bank_panel shell owns every pixel and draws the
sample grid with LICE in `WM_PAINT`". If the panel is created with resource id 0 on
non-Windows, **the entire resgen pipeline — the PHP dependency (`README.md:54`), the
generated-file-in-source-tree problem, and the per-platform regeneration story — disappears
from the Linux and macOS builds.** The cost is a source change in `panel_window.cpp` and the
DLGPROC→WNDPROC return-type contract; that half is T2's call, not mine, and I am naming the
option rather than choosing it. This is a shared macOS/Linux fix either way.
**If the resgen route is taken instead**, the build work is: a `find_program(PHP …)`, an
`add_custom_command` generating into `${CMAKE_CURRENT_BINARY_DIR}` (not the source tree —
`.gitignore` has no pattern matching `resource.rc_mac_dlg`, so today's manual step would
leave an untracked generated file sitting in `src/`), a new one-line shim TU that includes
`swell-dlggen.h` then the generated file, and that shim added to `target_sources`. That is
the M-effort half of the band.
---
### Λ-02 — `--config Release` is a silent no-op on Linux, so the documented ship command produces an unoptimized binary
**Severity: Major. Effort: S — one `if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)` block plus a doc correction.**
**Citations.** `CMakeLists.txt:2830` (the complete list of language settings — I read the
whole 95-line file and there is no `CMAKE_BUILD_TYPE` anywhere); `README.md:106109`;
`CLAUDE.md` §"Build and test" and §"Install / reload".
**What breaks.** Both docs prescribe `cmake --build build --config Release` as the
optimize-and-ship incantation, and `README.md:130131` tells the user to copy from
`build/Release/`. `--config` is a multi-config-generator flag; on Ninja or Unix Makefiles it
is accepted and ignored. Because no `CMAKE_BUILD_TYPE` default is set, the resulting build
passes **no `-O` flag at all** to GCC/Clang — not `-O0` explicitly, just the compiler's
unoptimized default. The failure is silent in both directions: the command succeeds, and the
artifact lands at `build/reaper_reasampler.so` — a path the docs never state for a
single-config generator (a gap, not an error; see Λ-08).
This matters more here than the usual "forgot to set the build type" — but only for `peaks`.
`CLAUDE.md`'s §"Build and test" (not §"Performance guardrails") states, at `CLAUDE.md:54`,
that the header-inline hot paths "presume an **optimizing** build", with no LTO configured to
recover it if they don't. `peaks`' `computeEnvelope`/`lastFrameAboveThreshold` is one of
those paths and IS linked into the extension (`src/app/CMakeLists.txt:56`), so the finding
holds for Fork A as stated. It does **not** extend to `Voice::advanceFrame` or the three
`envelopes.h` evaluators (named at `src/core/instrument/CLAUDE.md:378`, not in root
`CLAUDE.md`) — those belong to `sampler_core`, which `src/app/CMakeLists.txt:5759` states is
deliberately NOT linked into the extension and which only enters a build at all inside the
Windows-gated `reasampler_vst` target (§1b). On Fork A that code never compiles into
`reaper_reasampler` in the first place; the unoptimized-build risk to it is a Fork B concern,
not this extension-only one. A Linux user following the README's `--config Release` would
ship an extension binary whose `peaks` hot path did not inline.
**Done when:** `cmake --build build` on Linux (Ninja/Make, no `--config`) produces a build
whose compile commands (`compile_commands.json` or verbose build log) show an explicit `-O`
flag, and the README/CLAUDE.md ship instruction names the single-config-generator path.
**Sequencing note.** This is not strictly Linux-specific — Ninja on Windows has the same
shape — but it becomes load-bearing the moment Linux is a shipped platform, because Makefiles
and Ninja are the default there.
---
### Λ-03 — no symbol-visibility control, and two modules in one process both export a full copy of LICE
**Severity: Major. Effort: S — one `CXX_VISIBILITY_PRESET hidden` + `VISIBILITY_INLINES_HIDDEN` pair, plus a `[verify — Linux]` load test.**
**Citations.** `CMakeLists.txt:7883` (`LICE_SRC`); `src/app/CMakeLists.txt:35` and
`src/shell/instrument/CMakeLists.txt:80` (the same four LICE TUs compiled into *both*
modules — the rationale for the duplication is at `CMakeLists.txt:7577`);
`vendor/reaper-sdk/sdk/reaper_plugin.h:52`; `src/app/main.cpp:251`;
`vendor/vst3sdk/pluginterfaces/base/fplatform.h:164`;
`vendor/WDL/WDL/swell/Makefile:20`.
**What breaks.** Nothing in the build sets a visibility preset, so GCC/Clang default to
`-fvisibility=default` and every non-static symbol in both modules — all of LICE, all the
WDL helpers, every pure library's exported functions, and the whole C++ standard-library
instantiation set — lands in the module's dynamic symbol table. On Windows this is a
non-issue: each DLL has its own symbol table and per-module resolution. `[verify — Linux]`
on ELF, symbols from `dlopen`'d objects CAN participate in global interposition — but whether
they actually do here depends on REAPER's own `dlopen` flags (`RTLD_GLOBAL` vs `RTLD_LOCAL`)
and whether REAPER's executable is linked `--export-dynamic`, neither of which this tree can
answer. The scenario this doc originally named — a hypothetical `reasampler_9000.so` — does
not exist on Fork A: per Λ-10 and `src/shell/instrument/CMakeLists.txt:9`, the VST3 target is
gated `WIN32 AND EXISTS ...` and is never built on Linux unless D1 selects Fork B. The
Fork-A-real risk is different: any **second REAPER extension** loaded in the same REAPER
process (a third-party `.so` in `UserPlugins/`) that links its own copy of LICE/WDL and also
defaults to `-fvisibility=default` is a candidate to interpose with `reaper_reasampler.so`'s
`LICE_FillRect` and the rest of the exported LICE/WDL symbol set. That is a class of bug that
presents as "one extension's LICE draws with another extension's LICE build" and is invisible
on Windows, where each DLL keeps its own symbol table.
**Why hiding is safe here.** The two symbols that genuinely must be exported already say so
explicitly: `reaper_plugin.h:52` defines
`REAPER_PLUGIN_DLL_EXPORT __attribute__((visibility("default")))` on non-Windows, and
`main.cpp:251` uses it (`extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(...)`).
On the VST3 side, `fplatform.h:164` defines `SMTG_EXPORT_SYMBOL` the same way. SWELL's own
build already uses `-fvisibility=hidden` (`Makefile:20`), which is the precedent. The one
thing to confirm on a Linux box is that `swell-modstub-generic.cpp:135`'s `SWELL_dllMain`
survives — it carries its own `__attribute__ ((visibility ("default")))` at that line, so it
should, but the modstub is compiled by *our* build, not SWELL's.
*Shared macOS/Linux — the visibility default and the LICE-TU duplication (`CMakeLists.txt:7577`)
apply the same way under the APPLE branch's Mach-O two-level namespace, though the concrete
Fork-A-real interposition mechanism above is ELF-specific; macOS's own dynamic-linker symbol
resolution is a separate `[verify]` this doc does not attempt.*
---
### Λ-04 — nothing links a thread library, and `<pthread.h>` is in the extension's include graph
**Severity: Major (VST3 target) / Minor (extension). Effort: S — `find_package(Threads)` + `Threads::Threads`.**
**Citations.** `src/shell/panel/panel_state.h:2024`;
`vendor/vst3sdk/base/thread/source/flock.cpp:71133`;
`src/shell/instrument/CMakeLists.txt:1539`; `vendor/WDL/WDL/swell/Makefile:90`.
**What breaks.** No CMake file in the tree calls `find_package(Threads)` or links
`Threads::Threads`/`-lpthread`/`-ldl` (grep-verified across all 23). Two separate exposures:
- **Extension.** `panel_state.h:23` includes `<pthread.h>` on non-Win32 — and it is not the
only entry point: `vendor/reaper-sdk/sdk/reaper_plugin.h:50` includes `<pthread.h>`
unconditionally in its non-Windows branch, so it is in **every** shell TU's include graph
(every `.cpp` that includes `reaper_plugin.h`/`reaper_plugin_functions.h`), not just the
one panel file. This strengthens rather than changes the finding: I still did not find a
pthread *call* in `src/shell/`'s own code, so this may be an include-only dependency that
needs no link flag on a modern glibc (where the pthread symbols are in libc proper). Stays
Minor, and `[verify — Linux]`.
- **VST3 target.** `flock.cpp` is unambiguous: on non-Windows it calls
`pthread_mutexattr_init`/`_settype`/`_destroy` (`:7176`), `pthread_mutex_init` (`:74`),
`pthread_mutex_destroy` (`:89`), `_lock` (`:105`), `_unlock` (`:121`) and `_trylock`
(`:133`). It is unconditionally in the `vst3_sdk` source list
(`src/shell/instrument/CMakeLists.txt:27`). Any Linux VST3 target must link a thread
library. SWELL's own Makefile uses `-lpthread -ldl` (`:90`) for the same reason.
Adding `find_package(Threads REQUIRED)` + `target_link_libraries(... Threads::Threads)` is
correct on all three platforms and costs nothing on Windows.
**Done when:** the extension links with `Threads::Threads` removed (proving the include-only
half needs no flag, or naming the symbol that forces it — see V5), and, if Fork B is taken,
`reasampler_vst` links clean with `Threads::Threads` present.
*Shared macOS/Linux — `reaper_plugin.h:50`'s non-Windows `<pthread.h>` include and
`flock.cpp`'s non-Windows pthread calls apply identically to the APPLE branch.*
---
### Λ-05 — `reaper_plugin.h`'s SWELL include resolves only by coincidence of the `-I` line
**Severity: Minor. Effort: S — a comment, or an `INTERFACE` target that carries both include dirs as one unit.**
**Citations.** `vendor/reaper-sdk/sdk/reaper_plugin.h:4254`; `CMakeLists.txt:6566`;
`src/app/CMakeLists.txt:56`; `src/shell/instrument/CMakeLists.txt:97`.
**What breaks — and, today, does not.** On non-Windows, `reaper_plugin.h:49` does
`#include "../WDL/swell/swell.h"`. Relative to the including file's own directory
(`vendor/reaper-sdk/sdk/`) that resolves to `vendor/reaper-sdk/WDL/swell/swell.h`, which does
not exist — listing `vendor/reaper-sdk/` shows only `README`, `reaper-plugins`, and `sdk`.
The include is nevertheless satisfiable, because the quoted-include fallback searches the
`-I` path, and `WDL_INC` is `${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL` (`CMakeLists.txt:66`) —
so `WDL_INC` + `../WDL/swell/swell.h` normalizes to `vendor/WDL/WDL/swell/swell.h`, which
exists. Both module targets pass `${WDL_INC}` (`src/app:56`, `src/shell/instrument:97`), so it
holds today.
The hazard is that this is a load-bearing coincidence with no comment anywhere, and it is
**invisible on Windows** — `reaper_plugin.h:43` takes `<windows.h>` instead, so dropping
`${WDL_INC}` from any target that includes `reaper_plugin.h` would pass CI on Windows and
fail only on Linux/macOS. Worth pinning before more targets are added.
---
### Λ-06 — the build has no platform partition of its source list, so every T2 finding lands here as a CMake change
**Severity: Minor (structural). Effort: S per TU once T2 says which ones need it.**
**Citations.** `src/app/CMakeLists.txt:851` and `:7198`;
`src/shell/actions/drag_out_win.cpp:8`, `:249`, `:282`.
**Mechanism.** All 38 shell TUs (plus `app/main.cpp`, 39 listed TUs total) are listed unconditionally (`:851`); the platform block at
`:7198` only *adds* sources, never removes or substitutes any. Every TU therefore enters a
Linux compile, and the only thing standing between the build and a compile error is each
file's own `#ifdef` discipline. Today that discipline is uneven in a way worth recording:
- `drag_out_win.cpp` is the model — `#ifdef _WIN32` at `:8` with a real
`// ---- macOS / Linux (SWELL)` branch at `:249` and `#endif` at `:282`.
- `arrange_drop_win.cpp` and `instrument_drop_win.cpp` contain **zero** occurrences of
`_WIN32` (grep count 0 each) and include no `<windows.h>` — they are `_win`-suffixed for
the surface they serve, not for a platform dependency, and appear portable by inspection.
This is not itself a defect; it is the reason the ordering sketch in §5 puts "read T2's
output, then partition the source list" as a distinct step rather than folding it into the
resgen work.
---
### Λ-07 — `std::filesystem` in a pure library, with no stated minimum compiler
**Severity: Minor. Effort: S — a `cmake_minimum_required`-adjacent compiler check or a documented floor.**
**Citations.** `src/core/capture/capture_paths.cpp:5` (`#include <filesystem>`) and `:95`
(`std::filesystem::path(rppPath).parent_path().string()`); `README.md:5054`.
**What breaks.** `capture_paths` is a *pure* library with its own CTest target
(`src/core/capture/CMakeLists.txt:12`), so this is on the test-harness path as well as the
extension's. GCC 8 requires an explicit `-lstdc++fs` for `std::filesystem`; GCC ≥ 9 and
libstdc++ ≥ 9 do not. `README.md:51` states only "A C++17 compiler" as the prerequisite, and
`cmake_minimum_required(VERSION 3.19)` (`CMakeLists.txt:1`) says nothing about the compiler.
On any current distro toolchain this is a non-issue; it becomes one on an older CI image.
Twelve other files in `src/shell/` also use `<filesystem>` (grep-verified), but one —
`src/shell/instrument/instrument_bake.cpp` — is in the `reasampler_vst` target, not the
extension: eleven in `reaper_reasampler`, sharing the fix below; the twelfth shares it only
if/when Fork B builds that target on Linux.
**Done when:** `capture_paths_tests` links on the oldest toolchain the phase targets without
`-lstdc++fs` added explicitly, or that flag is added and documented as the floor.
*Shared macOS/Linux — `capture_paths` and the eleven extension-side `<filesystem>` users
compile identically under the APPLE branch; whether Apple's libc++ needs the same kind of
minimum-toolchain floor as libstdc++ does is a separate `[verify]` this doc has not checked,
but the coverage-hole shape (undocumented compiler floor) is identical either way.*
---
### Λ-08 — the install instruction is silent about single-config generators, and no path is documented for Linux at all
**Severity: Minor. Effort: S — a doc fix, or an `install()` rule if packaging is ever automated.**
**Citations.** `src/app/CMakeLists.txt:68` (`LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}"`);
`README.md:130131`; `CLAUDE.md` §"Install / reload";
`docs/product/versioning-and-release.md:364366`.
**What breaks — a gap, not an error.** `README.md:130131` and `CLAUDE.md` §"Install /
reload" both already carry the multi-config qualifier verbatim ("`build/Release/` on a
multi-config generator — not the default `Debug/` output") — so the instruction is not
wrong, it is simply silent about the other case. On a multi-config generator CMake appends
the config directory, giving the documented `build/Release/`. On Ninja/Make the module lands
directly at `build/reaper_reasampler.so`, and neither doc names that path. Compounding it,
there is no `install()` rule anywhere in the 23 CMake files, and no target directory is
documented for Linux at all — `CLAUDE.md` and `README.md:132` say "REAPER's `UserPlugins/`
folder (Options → Show REAPER resource path)", which is a correct user-facing instruction but
not a path a pipeline can use. I could not confirm REAPER's Linux resource-path root from the
SDK: grepping `vendor/reaper-sdk/sdk/` for `UserPlugins` and `reaper_*.so` returns nothing —
the `reaper_*.dll|dylib|so` filename convention stated in `CLAUDE.md` is not documented in the
vendored headers. `[verify — Linux]`.
---
### Λ-09 — no warning flags anywhere, so the first GCC/Clang build surfaces a diagnostic set nobody has ever seen
**Severity: Minor. Effort: S to add flags; **unknowable** to fix what they reveal — that is the honest band.**
**Citations.** grep for `target_compile_options` across all 23 CMake files: zero hits (the
only `compile_definitions` hits are `SWELL_PROVIDED_BY_APP` at `src/app/CMakeLists.txt:80`,
`:92` and the SDK's `DEVELOPMENT`/`RELEASE` selector at
`src/shell/instrument/CMakeLists.txt:42`). `CMakeLists.txt:2830` for the absence of
`CMAKE_CXX_EXTENSIONS`.
**Mechanism.** The codebase has been compiled by exactly one compiler family. MSVC's default
is `/W1`; GCC/Clang's default is essentially silent. Adding `-Wall -Wextra` on the first
Linux build is the right move, but the size of the resulting diagnostic set over ~39k LOC is
not estimable from Windows, which is why the effort band is split. Do **not** add `-Werror`
in the same change.
Secondary, in the same family: `CMAKE_CXX_EXTENSIONS` is unset, so it defaults to `ON` and
GCC/Clang compile as `-std=gnu++17` rather than `-std=c++17`. Not a bug; a hygiene knob
worth flipping while the flags are being set, since it is the difference between "portable
C++17" and "whatever GNU extensions happened to be used".
**Coverage hole: the link-succeeds-but-load-fails asymmetry is never stated.** GNU `ld` does
not error on undefined symbols in a shared object by default — MSVC's linker does. For a
`MODULE` REAPER `dlopen()`s, that means a Linux build can link `reaper_reasampler.so` clean
and only discover a missing symbol when REAPER tries to load it (`dlopen` failing, or a
partially-resolved symbol table if a satisfying-by-coincidence symbol exists elsewhere in the
process — a real risk given Λ-03's global-interposition finding above). Neither `Threads`
(Λ-04) nor visibility (Λ-03) named this asymmetry, and it undercuts §5's framing of V1
(`cmake --build build`) as "the single highest-information action available" — a clean build
on Linux does not carry the same guarantee a clean Windows build does. `-Wl,--no-undefined`
(GNU ld) would restore the MSVC-equivalent fail-at-link-time behavior; whether it is needed
here specifically depends on whether `SWELL_PROVIDED_BY_APP`'s function-pointer design (§1a)
already accounts for every symbol the modstub doesn't itself define — that is `[verify —
Linux]`, since it requires an actual GCC/Clang link to observe.
**Done when:** the Linux build either adds `-Wl,--no-undefined` (or the equivalent) so an
undefined-symbol build failure surfaces at link time, or a stated reason is recorded for why
`SWELL_PROVIDED_BY_APP` makes the gap moot.
*Shared macOS/Linux — `-Wall -Wextra`, `CMAKE_CXX_EXTENSIONS OFF`, and the GNU-ld-vs-MSVC-ld
asymmetry (macOS's linker also does not default to erroring on undefined symbols) apply
identically to the APPLE branch.*
---
### Λ-10 — the submodule story costs a Linux developer a large download for a target that is gated off
**Severity: Minor. Effort: S — a doc paragraph.**
**Citations.** `.gitmodules` (three entries, no `shallow`, no `branch`);
`README.md:7086`; `src/shell/instrument/CMakeLists.txt:9`; `CLAUDE.md` §"One-time submodule setup".
**Mechanism.** `README.md:7275` already warns that `git clone --recursive` pulls all of
`vendor/vst3sdk`'s nested submodules (`vstgui4`, `tutorials`, `doc`, `cmake`) that this
project never links, and prescribes the narrow init instead. What neither the README nor
`CLAUDE.md` says is that on Linux the `vendor/vst3sdk` submodule is **pure cost**: the
`WIN32 AND EXISTS` gate at `src/shell/instrument/CMakeLists.txt:9` omits the target
regardless, so `git submodule update --init vendor/reaper-sdk vendor/WDL` is the complete
Linux prerequisite. One sentence in the platform-support table would save every Linux
contributor the download.
---
## 3. Surfaces checked and found clean
Recorded so the plan author does not re-spend effort here. Each was read or grepped in this
pass.
- **All 23 `CMakeLists.txt` files were read in full**, not sampled: root; `src/app`;
`src/core`; `src/core/{audio,capture,instrument,json,model,reclaim,tracking,ui,util,version,view,wire}`;
`src/core/instrument/{bake,engine,map,note,ui}`; `src/core/instrument/engine/{filter,loop}`;
`src/shell/instrument`. Plus `cmake/reasampler_targets.cmake`. Of these, 21 are entirely
platform-neutral — they declare libraries and tests through the two helpers and nothing
else.
- **`CMAKE_POSITION_INDEPENDENT_CODE ON`** (`CMakeLists.txt:30`) — the usual Linux
`MODULE`-fed-by-`STATIC` trap, already handled.
- **`.so` naming.** `PREFIX ""` (`src/app/CMakeLists.txt:66`) + `SUFFIX ".so"` (`:93`) +
channel-derived `OUTPUT_NAME` (`:67`) yields `reaper_reasampler.so` /
`reaper_reasampler_beta.so`, matching the `reaper_*` convention. `ARCHIVE_OUTPUT_DIRECTORY`
(`:69`) is a Windows import-lib concern and harmlessly inert on Linux.
- **The channel fork is platform-independent.** `REASAMPLER_CHANNEL` (`CMakeLists.txt:4054`)
threads through `configure_file` (`:5861`) into names only; nothing in it is
Windows-specific, so beta builds fork identically on Linux.
- **The version padding canary** (`src/core/version/CMakeLists.txt:2142`) uses a
function-scoped `set()` + second `configure_file` + longhand `add_executable`. Portable;
no platform assumption.
- **The whole test corpus is platform-neutral.** 91 files in `tests/`, 91 declarations in
CMake. Only `test_capture_paths.cpp` branches on platform and it already has POSIX
expectations written; only `test_pitch_shift.cpp` mentions `windows.h`, in a comment.
- **Generator-shape handling in the docs is already correct.** `README.md:9398` explains
that `-C Debug` is required on multi-config and unnecessary on Ninja/Make. `ctest --test-dir
build` works bare on Linux; the `-C Debug` in the README's example is harmless there.
- **SWELL API coverage for the LICE slice** — all six SWELL GDI entry points LICE needs are
in the modstub-resolved table (`swell-functions.h:857`, `:863`, `:877`, `:885887`).
- **The LICE slice needs no fifth TU on Linux** (`lice.cpp:3040` `_LICE_RGB2HSV_invtab`
table, `lice_textnew.cpp:26` file-static `utf8makechar`) — an inference from V1's link
success plus these two spot checks, not exhaustive; see §1a's fuller caveat, including the
unreconciled `lice_colorspace.o` in SWELL's own `Makefile:106107`.
- **`swell-modstub-generic.cpp` compiles against its own includes** — it uses `printf`
(`:61`), which arrives via `swell.h``swell-types.h:29` (`#include <stdio.h>`). The
`dlopen`/`readlink` block (`:69131`) is behind `SWELL_LOAD_SWELL_DYLIB`, which this build
never defines.
- **The Linux run-loop IIDs are already in the vendored VST3 slice.**
`public.sdk/source/common/commoniids.cpp:5054` defines `Linux::IEventHandler`,
`Linux::ITimerHandler` and `Linux::IRunLoop` under `#if SMTG_OS_LINUX`, and that file is
already in the `vst3_sdk` source list (`src/shell/instrument/CMakeLists.txt:36`). A Linux
VST3 port needs no additional SDK translation unit for the run loop.
- **`SMTG_OS_LINUX` / `SMTG_EXPORT_SYMBOL` are correct in the pinned SDK**
(`pluginterfaces/base/fplatform.h:132`, `:164`).
- **The MSVC-ABI caveat is Windows-scoped.** `reaper_plugin.h:22` warns that the C++
interfaces "require MSVC on win32, or at least the MSVC-compatible C++ ABI" — that is a
constraint on Windows toolchains (mingw), not on Linux, where REAPER and the extension both
use the Itanium ABI.
---
## 4. The VST3-on-Linux question, stated as a fork
Daniel rules on this. Below is the cost of each side with the evidence, and nothing else.
### Fork A — extension only on Linux
Scope: everything in §2 (Λ-01 through Λ-10). The VST3 gate at
`src/shell/instrument/CMakeLists.txt:9` stays exactly as it is; a Linux configure omits the
target and the `vendor/vst3sdk` submodule becomes optional there (Λ-10).
**Cost profile:** one Major that must be solved (Λ-01, the panel), two Majors that are
one-line-ish but load-bearing (Λ-02 optimization, Λ-03 visibility), one Major/Minor
(Λ-04 threads), and six Minors. No new third-party dependency. No new artifact format. No
change to any product decision on record.
**The unknown that dominates the band:** none of the extension's source portability is
audited here. The build-system work is small; whether the 38 shell TUs *compile and behave*
against SWELL's Win32 subset is T2's answer, and it, not this list, sets the phase's size.
### Fork B — extension + ReaSampler 9000 on Linux
Everything in Fork A, plus the following. Three of five are Blockers under the severity
vocabulary defined above ("the Linux build cannot produce a loadable artifact"): B1, B2, B3.
B4 downgrades to Major — the module still loads; only the editor fails to attach without it.
B5 is not a technical blocker at all — it is a documentation act, though one that must
precede the others in sequence (see §6 Stage 5).
- **B1 — the entry point is the wrong one, and the right one is already vendored.**
`src/shell/instrument/CMakeLists.txt:78` compiles
`public.sdk/source/main/dllmain.cpp` into the module. That file does
`#include <windows.h>` at `:41` with no `SMTG_OS_*` guard — grepping the file for
`SMTG_OS` returns nothing, and its only conditionals are
`#if defined(_MSC_VER) && defined(DEVELOPMENT)` (`:43`, `:96`) and `#ifdef __cplusplus`
(`:58`, `:87`). Linux needs
`public.sdk/source/main/linuxmain.cpp`, which is present in the slice and exports
`ModuleEntry`/`ModuleExit` (`extern "C"` declarations `linuxmain.cpp:4752`; definitions
`:5967` and `:7282` respectively). Both are **mandatory** — the
SDK's own loader refuses the module without either
(`public.sdk/source/vst/hosting/module_linux.cpp:189204`, error strings "The shared
library does not export the required 'ModuleEntry' function" and the same for `ModuleExit`).
`GetPluginFactory` is checked at `:205`. **Effort S** — a `CMakeLists.txt` source swap
(`dllmain.cpp``linuxmain.cpp`) plus the platform `if()`; both files are already vendored,
nothing to write from scratch.
- **B2 — the artifact is a directory bundle, not a file.** Today the target sets
`SUFFIX ".vst3"` on a MODULE (`src/shell/instrument/CMakeLists.txt:106111`), producing a
single file. `module_linux.cpp:146170` (`getSOPath`) shows what a Linux host actually
opens: the path must be a **directory**, containing `Contents/`, containing
`<machine>-linux/` where `<machine>` is `uname().machine` (`:158163`, helper at `:8897`),
containing `<stem>.so` (`:167168`). So `reasampler_9000.vst3/Contents/x86_64-linux/reasampler_9000.so`.
That is `add_custom_command(POST_BUILD)` work plus an install rule. Install roots are
`$HOME/.vst3/`, `/usr/lib/vst3/`, `/usr/local/lib/vst3/` and `$APPFOLDER/vst3/`
(`module_linux.cpp:282302`). `Contents/Resources/moduleinfo.json` is **optional**
`getModuleInfoPath` (`:356365`) returns empty when the file is absent rather than failing.
**Effort M** — mechanical, but it is a new artifact shape with an install story and a
per-architecture directory name.
- **B3 — the drawing stack is the real cost, and it is where Fork B stops being a build task.**
The VST3 module compiles the same four LICE TUs (`src/shell/instrument/CMakeLists.txt:80`),
and on non-Win32 LICE resolves its GDI through SWELL (§1a). But **a VST3 plugin is not a
REAPER extension**: `swell-modstub-generic.cpp`'s default branch exports `SWELL_dllMain`
(`:135`), which only REAPER's *extension* loader calls. Nothing hands a VST3 plugin the
SWELL function table. That leaves two routes:
- **Route B3a — `SWELL_LOAD_SWELL_DYLIB`** (`swell-modstub-generic.cpp:69131`): the stub
reads `/proc/self/exe`, strips to the directory, and `dlopen`s `libSwell.so` beside the
host binary (`:8198`), then calls `SWELL_set_app_main(SWELLAppMain)` (`:112114`). That
reference makes `SWELLAppMain` a **required definition in the plugin** — it is declared at
`swell-types.h:137` as "to be implemented by app (if using swellappmain.mm)". Whether
REAPER's Linux install ships a
`libSwell.so` beside its executable is `[verify — Linux]`, and this route makes the VST3
load-bearing on being hosted by REAPER specifically, which is consistent with D5's
REAPER-only stance but is a new runtime coupling to state explicitly.
- **Route B3b — build SWELL into the module.** `vendor/WDL/WDL/swell/Makefile:100104`
enumerates the 14 SWELL TUs (`swell.o swell-ini.o swell-miscdlg-generic.o
swell-wnd-generic.o swell-menu-generic.o swell-kb-generic.o swell-dlg-generic.o
swell-gdi-generic.o swell-misc-generic.o swell-gdi-lice.o swell-generic-headless.o
swell-generic-gdk.o swell-appstub-generic.o swell-modstub-generic.o`) and `:106107` the 8
LICE TUs it needs alongside. Link requirements at `:90` (`-lpthread -ldl`), `:128130`
(pkg-config `gtk+-3.0` or `gdk-3.0`), `:145151` (`freetype2` + `-lfontconfig`) and
`:154` (`-lGL`).
**This is a wholly new third-party dependency surface** — GDK/GTK3, FreeType, Fontconfig,
OpenGL — that the extension deliberately avoids via `SWELL_PROVIDED_BY_APP`. Flagging it
as a scope expansion, per the dispatch's constraint; it is not something to slip in.
**Effort L either way**, and B3b is the larger of the two by a wide margin.
- **B4 — the editor's platform contract changes.** The view must report
`kPlatformTypeX11EmbedWindowID` (`pluginterfaces/gui/iplugview.h:79`) instead of
`kPlatformTypeHWND` (`:62`), and timers/file-descriptors must go through
`Steinberg::Linux::IRunLoop` (`iplugview.h:267279`) with `IEventHandler` (`:223229`) and
`ITimerHandler` (`:239245`) — the header states the reason at `:255256`: "On Linux the
host has to provide this interface to the plug-in as there's no global event run loop
defined as on other platforms." The whole `namespace Linux` block is `#if SMTG_OS_LINUX`
(`:209`, closed `:284`), so it compiles in automatically. The **build-side** cost is nil
(the IIDs are already vendored — see §3); the **source-side** cost is `editor_platform.cpp`
and every timer in the editor, which is T2's surface. **Effort: build S, source L** — build
is S because no new CMake source is needed (the SDK block compiles in automatically); source
is L because it is a genuine window/run-loop rewrite (an X11 embed contract with no Win32
analogue, not a mechanical port) and outside this doc's own portability scope to size
further.
- **B5 — a settled product decision has to be reversed, in writing.** D5 ("Windows-only,
VST3-only, REAPER-only") is recorded as a locked decision with "a mac/Linux build" named
as a non-goal in `src/core/instrument/CLAUDE.md` (Invariants → the three locked decisions;
and again under Non-goals/guardrails) and `src/shell/instrument/CLAUDE.md`
(Non-goals/guardrails). `src/shell/panel/CLAUDE.md` carries a third dependent statement.
Fork B is not just more work — it invalidates three recorded invariants and needs Daniel's
explicit reversal before any code moves. **Effort S** — no code or CMake change of its own,
a rewrite of the three `CLAUDE.md` passages naming D5 plus Daniel's sign-off; the L-sized
cost sits in B3/B4, not here.
**The asymmetry worth naming.** Fork A's build work is small and its unknown is source
portability. Fork B's build work is genuinely large (B2, B3) *and* carries an unbounded
source-portability tail (B3b's dependency surface, B4's window/run-loop rewrite) *and*
requires reversing a settled decision. The two are not "the same phase, one bigger" — they
are different-shaped commitments.
---
## 5. Open questions
### `[verify — Linux]` — answerable by trying it on a Linux box
| # | Question | Exact check |
|---|---|---|
| V1 | Does the extension compile at all under GCC/Clang? | `cmake -B build -S . -G Ninja && cmake --build build 2>&1 \| tee build.log` — the single highest-information action available and gates most of T2's list too, with one caveat (Λ-09): a clean link is not the same guarantee here as on MSVC, since GNU `ld` does not error on undefined symbols in a `MODULE` by default — V1 answers "does it compile and link", not "does it load". |
| V2 | Does `ctest` pass all 91 targets? | `cmake -B build -S . -G Ninja && cmake --build build && ctest --test-dir build --output-on-failure` (no `-C` needed on Ninja). |
| V3 | Does REAPER's Linux build call `SWELL_dllMain` in a `reaper_*.so`, populating the API table? | Drop the built `.so` in `~/.config/REAPER/UserPlugins/`, restart, and check the extension's actions appear. A null table shows as immediate crash or dead actions. |
| V4 | Where exactly is `UserPlugins/` on Linux, and is `reaper_*.so` the right filename glob? | Options → Show REAPER resource path in a Linux REAPER. The SDK headers do not document either — grep of `vendor/reaper-sdk/sdk/` for `UserPlugins` and `reaper_*.so` returns nothing. |
| V5 | Does `panel_state.h:23`'s `<pthread.h>` actually require a link flag on the target glibc? | Build without `Threads::Threads`; if it links, it does not. |
| V6 | Does `-fvisibility=hidden` leave `ReaperPluginEntry` and `SWELL_dllMain` exported? | `nm -D --defined-only reaper_reasampler.so \| grep -E 'ReaperPluginEntry\|SWELL_dllMain'` after adding the preset. |
| V7 | With Λ-01 fixed by the resource-id-0 route, does `SWELL_CreateDialog` return a usable child HWND for the docker? | Panel toggle action in a Linux REAPER. |
| V8 | Does `std::filesystem` need `-lstdc++fs` on the chosen toolchain? | Build `capture_paths_tests` alone; the link error names it. |
| V9 | Does REAPER's Linux install ship a `libSwell.so` beside its executable (Fork B, route B3a)? | `ls $(dirname $(readlink -f $(which reaper)))/libSwell.so`. |
| V10 | Which `uname -m` values must the VST3 bundle carry (Fork B, B2)? | `uname -m` on each target. `module_linux.cpp:158163` keys the bundle directory off exactly this string (tree-grounded and verified); which architectures REAPER itself ships for Linux (x86_64, aarch64, armv7l is the claim) is an external fact with no source in this tree — `[verify]` against REAPER's own download page before relying on it. |
### `[Daniel]` — genuine product/scope calls
| # | Question | Why it is not answerable by reading code |
|---|---|---|
| D1 | **Fork A or Fork B?** Extension only, or extension + ReaSampler 9000 on Linux? | §4 states both costs. The choice is scope, and D5 makes it a reversal of a recorded decision, not a technical lookup. |
| D2 | If Fork B: **route B3a (dlopen the host's `libSwell.so`, requires defining `SWELLAppMain`) or B3b (vendor and build full SWELL, adds GDK/GTK3 + FreeType + Fontconfig + OpenGL)?** | B3b is a dependency-surface expansion; the dispatch requires that be flagged rather than chosen. B3a is cheaper but couples the plugin's UI to being hosted by REAPER specifically. |
| D3 | Is **macOS in or out** of the same phase? | Λ-01, Λ-03, Λ-04, Λ-07 and Λ-09 are all marked *shared macOS/Linux* at their own sections above (Λ-01: identical broken comment block, `src/app/CMakeLists.txt:8386` vs `:9497`, and shared `swell_resgen.php` output per `README.md:120`; Λ-03/04/07/09: same mechanism under the APPLE branch, detailed at each). Doing both at once is cheaper than doing them serially — but that is a scope call, and macOS additionally implies signing/notarization (not mentioned anywhere in `versioning-and-release.md`) plus its own open question this doc does not resolve: whether `swell-modstub.mm` (`src/app/CMakeLists.txt:79`) even compiles under a CXX-only `project()` (`CMakeLists.txt:26`, no `OBJCXX`) — see §1a. |
| D4 | Does **CI get built in this phase**, and on what runner? | `versioning-and-release.md:307311` and `:361363` hand "the pipeline" to dev-ops and commit to three platform artifacts per channel, but no CI exists in the repo on any platform. Adding it is infrastructure and needs an explicit ask. |
| D6 | Is the Linux artifact **shipped or developer-only** for 1.x? | Determines whether Λ-02 (Release build) and Λ-08 (install path) are must-fix or nice-to-have, and whether a Linux `install()` rule is worth authoring. (Numbered D6, not D5, to avoid colliding with the settled product decision D5 — "Windows-only, VST3-only, REAPER-only" — cited above at §1b and B5.) |
| D7 | Λ-01's fork: **resource-id-0 escape hatch, or the resgen route?** §6 Stage 2 gates on T2's read of `panel_window.cpp`, and V7 presupposes the route is already picked. | It is a source-code + build-pipeline tradeoff (a `panel_window.cpp` DLGPROC→WNDPROC change vs. a PHP build step + generated-file handling) with no code-only right answer — needs Daniel's call once T2 reports, same as D1/D2. |
Deliberately **not** listed as `[Daniel]`, because reading more would settle them: whether
`arrange_drop_win.cpp`/`instrument_drop_win.cpp` need platform gating (read the files — T2);
whether the SDK's `moduleinfo.json` is required (it is not — `module_linux.cpp:362364`);
whether the Linux run-loop IIDs need a new SDK TU (they do not — `commoniids.cpp:5054`).
---
## 6. Ordering sketch
Dependency order only — this is sequencing input for a plan author, not the plan.
**Stage 0 — the one action that reprices everything.** Run V1 and V2 on a Linux box against
the tree as-is. Configure will succeed (nothing in the CMake requires Windows); the compile
will produce a diagnostic set that is the actual size of this phase. Every band below is
provisional until this is done. Λ-10's doc fix rides along free, since the person doing this
will hit it first.
**Stage 1 — toolchain floor, no source dependency.** Λ-02 (`CMAKE_BUILD_TYPE` default),
Λ-03 (visibility presets), Λ-04 (`Threads::Threads`), Λ-09 (warning flags + `CXX_EXTENSIONS
OFF`), Λ-05 (pin the `WDL_INC` coincidence), Λ-07 (compiler floor). All are root- or
target-property edits with no ordering constraint among them and none touching source. Doing
them *before* Stage 2 means the resource work is done under warnings rather than retrofitted
into them. Ship as one change.
**Stage 2 — the panel resource decision.** Λ-01. This forks on the resource-id-0 question
(§2, Λ-01) and therefore needs T2's read on `panel_window.cpp` first — so it is gated on T2
output, not on Stage 1. Whichever route wins, it is a shared macOS/Linux fix (see D3).
**Stage 3 — source partition.** Λ-06. Once T2 names which TUs need a platform branch or a
non-Windows sibling, the source list in `src/app/CMakeLists.txt:851` gains its
partition. Strictly after T2 and after Stage 2 (the resgen/shim TU, if that route is taken,
is one of the entries).
**Stage 4 — packaging.** Λ-08, plus whatever D6 decides. Depends on Stage 1 (there is no
point documenting an install path for an unoptimized artifact) and on V4.
**Stage 5 — Fork B, only if D1 says so.** Internally ordered: B1 (entry point swap, S) →
B3 (SWELL strategy per D2 — this gates everything downstream, because a module that cannot
draw cannot be tested) → B2 (bundle layout + install, needs V10) → B4 (X11 embed + run loop,
mostly T2's source work). B5 (the D5 reversal) precedes all of them as a documentation act.
---
## 7. Referred to T2
Source-level items I noticed while reading build files. **Recorded, not analyzed** — the
parallel track owns each of these and I have deliberately not investigated further.
1. `src/shell/panel/panel_window.cpp:135``CreateDialogParam` + `MAKEINTRESOURCE(IDD_BANK_PANEL)`.
Under SWELL this is `SWELL_CreateDialog(SWELL_curmodule_dialogresource_head, …)`. Relevant
to Λ-01's resource-id-0 option, which requires the proc to be a `WNDPROC` returning
`LRESULT`, cast to `DLGPROC` (`swell-functions.h:606608`).
2. `src/shell/actions/arrange_drop_win.cpp` and `src/shell/actions/instrument_drop_win.cpp`
zero `_WIN32` occurrences, no `<windows.h>`, yet `_win`-suffixed. Confirm they are
genuinely portable, or add guards.
3. `src/shell/actions/drag_out_win.cpp:249282` — the `#else` "macOS / Linux (SWELL)" branch
exists but has never been compiled. Whether the SWELL drag path is functionally equivalent
to the Win32 `IDataObject`/`IDropSource` path is unaudited.
4. `src/shell/persist/prune_fs.cpp:36``<windows.h>` behind two `_WIN32` guards. Confirm the
non-Windows deletion path exists and is equivalent (this file is on the only
file-deletion path in the product, per `src/core/reclaim/CLAUDE.md`).
5. `src/shell/panel/draw_kit.cpp:1115`, `draw_kit.h:2729`, `src/shell/panel/panel_state.h:1826`
the `windows.h`-vs-`swell/swell.h` include switches. These look correct; whether the
SWELL subset actually covers every GDI call the kit makes is the question.
6. `src/shell/panel/CLAUDE.md` records "Windows-only (D5) — font/GDI/HFONT choices assume
Windows; no cross-platform font-fallback concern" as an invariant. Font selection on
Linux SWELL (FreeType/Fontconfig-backed) is the concrete instance.
7. `src/core/capture/capture_paths.cpp``normalizeSlashes` lowercases on `_WIN32` only, and
`tests/test_capture_paths.cpp:1946` already encodes the case-sensitive POSIX contract.
The test is written; the behaviour under a case-sensitive filesystem is unverified.
@@ -0,0 +1,792 @@
# Λ-W1 Track 2 — source/runtime Linux-portability audit
Static analysis of `src/**/*.{cpp,h}` (293 files) plus the vendored SWELL / WDL / LICE /
REAPER-SDK / VST3-SDK headers those files consume, 2026-08-02, branch
`pl-w1-t2-source-runtime-audit` (cut from `dev` at 6e937b9). Answers one question: **what in
the source blocks or degrades a Linux build/run**, for the extension (`reaper_reasampler`) and,
separately, for the ReaSampler 9000 VST3 instrument (`reasampler_vst`).
Build-system surface (CMake, `cmake/`, resgen as a *build step*, vendor configuration,
packaging, install) is a parallel track's; anything spotted here is listed under
**§7 Referred to T1** rather than analyzed. macOS is out of scope except where a Linux fix
shares its path — noted inline, one line, never a separate finding.
**Evidence discipline.** Every capability claim about SWELL / LICE / the REAPER SDK / the VST3
SDK cites the vendor file it was read in. The author is on Windows and **cannot build or run on
Linux**: nothing below was compiled or executed on Linux. Claims that need a Linux box carry
`[verify — Linux]` with the exact check. Line numbers were re-read against the working tree
immediately before writing.
---
## 1. Baseline — the portability posture today, by layer
### 1.1 `core/` — genuinely portable. Verified, not assumed.
Two independent checks, both exhaustive over the directory:
- **Include surface.** Every `#include` in `src/core/**` is either a `"core/…"` sibling, one of
26 standard-library headers, or the CMake-generated `version_generated.h`
(`src/core/version/app_version.cpp:12`, `src/core/wire/reasampler_uid.h:13`). **Zero** REAPER,
SWELL, WDL, LICE, VST3-SDK, `windows.h`, or other vendor includes. The 26 headers are
`<algorithm> <array> <atomic> <cassert> <cctype> <cerrno> <climits> <cmath> <cstddef>
<cstdint> <cstdio> <cstdlib> <cstring> <filesystem> <fstream> <limits> <map> <optional>
<set> <sstream> <string> <type_traits> <unordered_map> <unordered_set> <utility> <vector>` —
all C++17, all present on libstdc++/libc++.
- **Preprocessor surface.** The whole of `core/` contains exactly **nine** preprocessor
conditional lines, in three places: the channel fork (`core/wire/reasampler_uid.h:29,34,39`),
two `NDEBUG` assert guards (`core/view/view_mode_model.cpp:326,328,331,334`), and **one**
platform conditional — `core/capture/capture_paths.cpp:1820`, the Windows-only case-fold in
`normalizeSlashes`. That is the entire platform-dependence of the pure core.
Byte-order is explicitly handled rather than assumed: `core/wire/bytes.h:2631,6274` builds and
reads little-endian by shift, not by `memcpy` of a native integer, so it is byte-order-neutral
by construction. `core/capture/wav_codec.h:5253` states the one real assumption ("target is
x86/ARM-LE only, no big-endian byte-swap") and `wav_codec.cpp:165168` memcpys floats under it —
correct on x86-64 and aarch64 Linux alike.
Sweeps that returned **nothing** across `src/core` **and** `src/app`: `sprintf_s`/`strcpy_s`/
`_stricmp`/`_snprintf`/`_alloca`/`__forceinline`/`__declspec`/`#pragma comment`/`#pragma warning`/
`#pragma pack`/`__int64`/`LPSTR`/`LPCSTR`/`LPWSTR`/`wchar_t`/`TCHAR`/`_T(`/`MAX_PATH`. There is
**no** `#pragma` of any kind anywhere in `src/**` other than `#pragma once`, and no
`__attribute__` / `__declspec` / anonymous-struct extension.
`long` appears in a handful of pure modules (`core/ui/card_meta.cpp:3941,55`,
`core/instrument/ui/envelope_overlay.cpp:45`). All are local computations from clamped doubles;
LP64's wider `long` is strictly safer than LLP64's. One stale comment
(`core/instrument/ui/envelope_overlay.cpp:19`, "a huge t would overflow a 32-bit long") is
Windows-specific but the clamp it describes runs regardless. Not a defect.
**Verdict: `core/` builds and behaves identically on Linux.** The one conditional
(`capture_paths.cpp:18`) is *correct* for Linux — case-sensitive paths must not be folded — and
the pure tests already assert both branches (`tests/test_capture_paths.cpp:1933,4151`), which
is direct evidence the pure layer was written with a non-Windows target in mind.
### 1.2 `tests/` — portable.
91 test TUs. Only `tests/test_capture_paths.cpp` forks on `_WIN32`, and it asserts the
non-Windows behaviour explicitly (lines 2833, 5051). A `C:\`/`C:/` grep over `tests/` hits
six files total, one of which is `test_capture_paths.cpp` itself; the five OTHER files — every
hit opaque *string data* fed to a pure function
(`test_bank_model.cpp:186187,243245`, `test_bake_wire.cpp:77`, `test_drag_out.cpp:291293,388`,
`test_origin_ledger.cpp:56`, `test_sample_usage.cpp:502`) — are not platform behaviour. They pass
identically on Linux.
### 1.3 `app/` — portable.
`src/app/main.cpp` is one TU of pointers + entry + dispatch. `REAPER_PLUGIN_DLL_EXPORT` and
`REAPER_PLUGIN_HINSTANCE` are already GCC/Clang-correct in the SDK
(`vendor/reaper-sdk/sdk/reaper_plugin.h:5253``__attribute__((visibility("default")))` and
`void *`), and `REAPER_PLUGIN_ENTRYPOINT` is platform-neutral (`:56`). No Win32 call, no Win32
type, no conditional. The one hazard is behavioural, not structural — see **L2-03**.
### 1.4 `shell/` — the whole cost sits here, and it is unevenly distributed.
65 platform-token lines across 30 of the 293 source files (grep:
`_WIN32|WIN32|__APPLE__|_MSC_VER|__linux__|__GNUC__|_WIN64` over `src/**/*.{cpp,h}`). Their
distribution is the story:
| Area | Lines | Shape |
|---|---|---|
| `shell/instrument/` (VST3 editor + embed) | 51 of 65 | Whole-TU `#ifdef _WIN32``#endif` — Windows-only by design |
| `shell/panel/` | 8 | Include-selection + two small `#else` bodies |
| `shell/persist/`, `shell/capture/`, `shell/actions/` | 4 | Trash fallback, `localtime_r`, the SWELL drag-out branch |
| `core/` | 2 | The case-fold |
Two `shell/` directories are absent from this table because they were swept and came back
completely clean, not because they went unchecked: `shell/view/` and `shell/bank_ops/` both
return **zero** hits on the same grep (confirmed). Naming them here, since the table above is
by hit-count and gives a reader no way to tell a zero-hit sweep from an unswept directory.
`src/resource.h` and `src/ext_keys.h` mention platform names only in comments — zero hits on
this specific grep (re-run and confirmed), so they are not a row of this distribution; see
`resource.h`/`resource.rc` under §7 for their actual (build-system) relevance.
The extension's guards are all *complete*: every `#ifdef _WIN32` that gates a **definition**
has an `#else` (`draw_kit.cpp:12`, `capture.cpp:218`, `panel_audition.cpp:36,58`,
`panel_state.h:20`, `prune_fs.cpp:169`, `drag_out_win.cpp:8`); the ones without an `#else`
gate only an `#include` or a single statement (`panel_window.cpp:19,148`, `prune_fs.cpp:35`,
`draw_kit.h:27`, `capture_paths.cpp:18`). **There is no undefined-symbol gap from this guard
structure** — every `#ifdef` that needs an `#else` has one. This is narrower than "no
undefined-symbol gap in the extension" full stop: L2-02's `GetCurrentProcessId`
(`instrument_drop_win.cpp:59`) is an undefined symbol on Linux precisely because it carries
**no** `#ifdef` at all, so it falls outside what this guard-completeness check measures.
The instrument's guards are complete in the opposite direction: `reasampler_editor.h:156269`
wraps the entire paint/input/timer/`wndProc`/`invalidate` family in `#ifdef _WIN32`, and
`editor_platform.cpp:298306` stubs only the three IPlugView overrides that must exist. Every
`invalidate()` call site is inside a guard (verified exhaustively over `src/shell/instrument/`:
53 call sites across eight files — `editor_input.cpp` (5), `editor_input_browse.cpp` (11),
`editor_input_chrome.cpp` (10), `editor_input_curve.cpp` (4), `editor_input_deck.cpp` (9),
`editor_input_waveform.cpp` (4), `editor_platform.cpp` (3, excluding the definition itself at
`:65`), and `editor_session.cpp` (7, at 119, 126, 139, 141, 150, 156, 174, excluding a
same-named mention inside a comment at `:106`) — each file whole-file- or whole-region-guarded
by `#ifdef _WIN32` (e.g. `editor_input_browse.cpp:8``:177`); `reasampler_editor.h:266` is
the declaration, not a call site. `editor_controls.cpp`, `editor_models.cpp`,
`instrument_bake.cpp` have zero call sites, confirmed). So the editor *links* off Windows — it
just does nothing.
### 1.5 What the platform layer actually provides (vendor-verified)
The extension's non-Windows path is SWELL-provided-by-host
(`SWELL_PROVIDED_BY_APP` → function pointers resolved through `SWELL_dllMain`,
`vendor/WDL/WDL/swell/swell-modstub-generic.cpp:135145`). `[verify — Linux]` **What is stated
here about REAPER's own `libSwell.so` is only evidence about WDL's own Makefile, not about what
Cockos actually ships** — `vendor/WDL/WDL/swell/Makefile:100107,141151` shows
`swell-gdi-lice.o`/`-DSWELL_LICE_GDI` built whenever GDK is enabled (`ifndef NOGDK`, `:111`),
with `-DSWELL_FREETYPE` gated behind `ifndef NOFREETYPE` (`:144`) and `-lfontconfig` gated a
level deeper behind `ifndef NOFONTCONFIG` (`:149`) — i.e. the Makefile makes the LICE-backed,
freetype/fontconfig SWELL the *default* build, not a certainty about the binary REAPER
distributes. **Every downstream claim built on "REAPER's Linux SWELL is the fontconfig-backed
GDK build" inherits this same unstated assumption** — that includes L2-09's entire mechanism,
the GDK-backend evidence cited for L2-07, and the cursor (`GDK_*`) / modifier-key
(`GetAsyncKeyState`) claims below, none of which are re-flagged individually; this paragraph is
their one shared source of doubt. Contrast with the null stub in `swell-gdi-generic.cpp`
(`#ifndef SWELL_PROVIDED_BY_APP`, line 24), which is not in play for a REAPER-hosted extension
either way.
Everything the panel layer calls was checked by name against
`vendor/WDL/WDL/swell/swell-functions.h` and `swell-types.h`. Present and real:
- Window/dialog: `SWELL_CreateDialog` behind the `CreateDialogParam` macro
(`swell-functions.h:616,619`), `DestroyWindow`, `SetFocus`, `GetFocus`, `GetParent`,
`GetCapture`/`SetCapture`/`ReleaseCapture`, `IsWindowVisible`, `InvalidateRect`,
`ScreenToClient`/`ClientToScreen`, `BeginPaint`/`EndPaint`, `SetTimer`/`KillTimer`,
`GetWindowLong`/`SetWindowLong` (`:319320`), `SetWindowPos` (`:286`).
- Messages/macros: `GET_X_LPARAM`, `GET_Y_LPARAM`, `HIWORD`, `MAKEINTRESOURCE`
(`swell-types.h:1220`), `PAINTSTRUCT`, `MSG` (`:262`), `SRCCOPY`, `WM_PAINT`, `WM_MOUSEMOVE`,
`WM_MOUSEWHEEL`, `WM_CAPTURECHANGED`, `WM_DROPFILES`, `WM_KEYDOWN`, `WM_TIMER`,
`WM_ERASEBKGND`, `TPM_RETURNCMD`, `MF_*`, `MB_*`, `ID*`, every `VK_*` used.
- `WM_MOUSEWHEEL` really does carry **screen** coords in `lParam` on the GDK backend — the
comment at `panel_window.cpp:100102` is correct (`swell-generic-gdk.cpp:1442`).
- `WM_CAPTURECHANGED` really is delivered on capture loss (`swell-generic-gdk.cpp:1795`,
`swell-wnd-generic.cpp:7141,7150`), so the panel's rollback handler
(`panel_window.cpp:9098`) has a live trigger.
- Cursors: `LoadCursor``SWELL_LoadCursor` (`swell-functions.h:728730`), `SetCursor`
(`:740741`), and **all seven** IDCs the panel uses map to real GDK cursors —
`IDC_ARROW``GDK_LEFT_PTR`, `IDC_HAND``GDK_HAND1`, `IDC_UPARROW``GDK_CENTER_PTR`,
`IDC_SIZEWE``GDK_RIGHT_SIDE`, `IDC_SIZEALL``GDK_FLEUR`, `IDC_IBEAM``GDK_XTERM`,
`IDC_NO``GDK_PIRATE` (`swell-generic-gdk.cpp:37363748` vs `panel_drag.cpp:141147,159161`).
- `GetAsyncKeyState` returns the `0x8000` high bit for `VK_CONTROL`/`VK_SHIFT`/`VK_MENU`
(`swell-generic-gdk.cpp:24392441`), so `panel_state.h:409411` works verbatim.
- `GetTickCount` is SWELL-provided (`swell-functions.h:81`), so `panel_drag.cpp:308,324` — the
only unguarded Win32-looking calls in the panel — are fine.
- `SWELL_InitiateDragDropOfFileList` **does exist on Linux**, and is a GDK implementation on
the GDK backend: `swell-generic-gdk.cpp:35633592` sets up a hidden drop-source window and
spins a nested `SWELL_RunMessageLoop` until capture drops, with a 500 ms no-motion timeout;
the actual `gdk_drag_begin` call is inside the shared `dropSourceWndProc` helper it invokes
(`:3446`), not textually inside the `35633592` range itself. The headless backend defines
the **same-named function as an empty no-op stub**
`swell-generic-headless.cpp:246248` is `void SWELL_InitiateDragDropOfFileList(...) { }` — so
the "not a stub" framing only holds for the GDK backend specifically. The two backends are
mutually exclusive (`swell-generic-headless.cpp:28` is `#ifndef SWELL_TARGET_GDK`), so nothing
breaks in a GDK build, but a reader should not take "does exist on Linux" to mean "is
implemented in every Linux SWELL backend." macOS (`swell-dlg.mm:3534`) also defines it; it is
declared at `swell-functions.h:1011`.
- LICE itself is portable: `lice.h:31` includes `swell-types.h` off Windows, `lice.cpp:22`
includes `swell.h`, `LICE_SysBitmap::__resize` has a SWELL framebuffer path
(`lice.cpp:179182`), and `LICE_CachedFont::DrawTextImpl` carries real non-Windows branches
(`lice_textnew.cpp:236249,829,10271028,1040`).
Absent from SWELL, verified by grep over `vendor/WDL/WDL/swell/`: `FF_DONTCARE` (**zero hits
anywhere in `vendor/WDL/`**), `GetCurrentProcessId` (only `GetCurrentThreadId`,
`swell-functions.h:822`), `GetKeyState`, `WM_MOUSELEAVE`/`TrackMouseEvent`, `DragAcceptFiles`,
`RegisterClass*`, `CreateWindowEx*`, `DefWindowProc*`, `MoveWindow`,
`GetWindowLongPtr`/`SetWindowLongPtr`, `WHEEL_DELTA`, and any move-to-trash surface.
---
## 2. Findings
### L2-01 — `FF_DONTCARE` does not exist off Windows; `draw_kit.cpp` will not compile
**Location:** `src/shell/panel/draw_kit.cpp:73` (`DEFAULT_PITCH | FF_DONTCARE`), reached on
non-Windows through `draw_kit.cpp:1216` (which includes `swell/swell.h`, **not** `windows.h`).
**Mechanism.** `swell-types.h` defines `DEFAULT_PITCH`, `DEFAULT_CHARSET`,
`OUT_DEFAULT_PRECIS`, `CLIP_DEFAULT_PRECIS`, `DEFAULT_QUALITY`, `FW_BOLD`, `FW_NORMAL`,
`FW_SEMIBOLD`, `TRANSPARENT` and every `DT_*` the kit uses — but **not** `FF_DONTCARE`. A grep
for `FF_DONTCARE` over the whole of `vendor/WDL/` returns nothing; on Windows it comes from
`<wingdi.h>` via `windows.h`. `draw_kit.cpp` is not platform-guarded (only its *include* is), so
the `CreateFont` call is compiled on every platform.
**Severity: Blocker** — `error: 'FF_DONTCARE' was not declared in this scope` at
`draw_kit.cpp:73`; `draw_kit` is linked into both loadable modules, so the extension does not
build at all.
**Effort: S** — the argument is `DEFAULT_PITCH | FF_DONTCARE`, and `FF_DONTCARE` is 0x00 in
wingdi.h; the family bits are advisory to Windows' font mapper and meaningless to fontconfig.
**Direction.** Drop the `| FF_DONTCARE` term, or define it locally in the non-Windows include
branch. Do not add `windows.h`.
### L2-02 — `GetCurrentProcessId()` is called with no platform branch and SWELL does not export it
**Location:** `src/shell/actions/instrument_drop_win.cpp:59` (temp `.vstpreset` filename). The TU
has **no** `_WIN32` conditional anywhere (verified: its only `#include`s are `<atomic> <cstdint>
<filesystem> <fstream> <string> <system_error> <vector>` plus project/SDK headers, lines 627).
**Mechanism.** On non-Windows the declaration would have to come from `reaper_plugin.h`
`swell.h`. `swell-functions.h` declares `GetCurrentThreadId` (`:822`) and no
`GetCurrentProcessId`; the only two occurrences in the whole WDL tree are inside
`WDL/shm_msgreply.cpp:26` and `WDL/win32_utf8.c:244`, neither of which is a SWELL export.
**Severity: Blocker** — `error: 'GetCurrentProcessId' was not declared in this scope`; the
extension does not build.
**Effort: S** — the PID exists only to keep two concurrent REAPER instances from colliding in
the shared temp dir (comment at `:5657`). `getpid()` behind a guard, or the already-imported
`GetCurrentThreadId()` plus the existing atomic counter, satisfies the same requirement.
**Direction.** Replace with a platform-neutral uniqueness source; the atomic counter at `:52`
already carries the intra-process half.
### L2-03 — `REAPERAPI_LoadAPI` is all-or-nothing over ~869 API names and fails silently
**Location:** `src/app/main.cpp:292293` (`if (REAPERAPI_LoadAPI(rec->GetFunc) != 0) return 0;`).
`main.cpp` does **not** define `REAPERAPI_MINIMAL`, so the full table is loaded
(`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:4448`; 1738 `REAPERAPI_WANT_` guards ≈ 869
entries).
**Mechanism.** The SDK's loader accumulates one `failcnt` across the entire table
(`reaper_plugin_functions.h`, `REAPERAPI_LoadAPI` body: `failcnt += !(*table[i].dest =
getAPI(table[i].name)); return failcnt;`). If a Linux REAPER build does not export *any single
one* of those 869 names, `main.cpp` returns 0 and the extension never loads — with **no console
message, no log line, nothing**. The SDK header carries no "Windows only" annotation on any
entry (grep for `windows only|win32 only|not on mac|not on linux` returns nothing), so nothing
here says a gap exists — but nothing rules it out either, and the failure mode is maximally
opaque.
**Severity: Blocker (conditional)** `[verify — Linux]` — a silent load refusal with no
diagnostic is the worst possible first-run experience for a port. **Scope note:** the
all-or-nothing mechanism itself is identical on Windows too
(`reaper_plugin_functions.h:4546` — "an older version of REAPER may not succeed in loading",
no platform fork in the loader) — nothing about the *mechanism* is Linux-specific, only whether
*this particular* build happens to be missing an entry is unknown. Graded here as a Linux
Blocker on failure-mode quality alone (per the Mechanism above), the same axis L2-04 is
graded on below, despite L2-04's mechanism being certain rather than speculative — see L2-04's
calculus note. Arguably this belongs as a cross-platform robustness note rather than a
Linux-exclusive finding; left here because the fix is trivial and worth doing regardless of
platform.
**Effort: S** — confirmed against the two Direction options below: the `ShowConsoleMsg`
diagnostic is one new line on the existing failure branch (`main.cpp:293`), and the
`REAPERAPI_MINIMAL` swap follows a pattern already used elsewhere in this codebase
(`panel_window.cpp:2631`, `panel_audition.cpp:1216`) rather than inventing a new one.
**Direction.** Either switch `main.cpp` to `REAPERAPI_MINIMAL` + an explicit `WANT` list (the
other TUs already do this — e.g. `panel_window.cpp:2631`, `panel_audition.cpp:1216`), or keep
the full load but print the failure count via `rec->GetFunc("ShowConsoleMsg")` before returning
0. The minimal list is also the honest inventory of what this extension actually needs.
### L2-04 — every persisted floating-point number is `LC_NUMERIC`-dependent, in both directions
**Location (writers):** `src/core/json/json.cpp:3943` (`%.17g` — the bank index, view model and
tracking ledger all serialize through it), `src/core/model/provenance.cpp:40` (`%.17g` — the
provenance blob inside the bank JSON). Two writers, not three: `tail_control.cpp:74` is a
comment describing the format ("Byte-identical to the former snprintf writer:
`{"mode":%d,"manualMs":%.17g}`"), not a write site — the actual per-project tail-setting write
is `tail_control.cpp:7879`, which already goes through `json::numToStr` (the same
`json.cpp:3943` codec cited above), so it is the same writer, not a third one.
**Location (readers):** `src/core/json/json.cpp:190196` (`std::strtod`),
`src/core/wire/wire.cpp:121130` (`Cursor::fieldDouble`, `std::strtod`),
`src/core/capture/render_settings.cpp:178186` (`std::stod` over REAPER's own `P_RAZOREDITS`).
**Mechanism.** `snprintf("%.17g")`, `strtod` and `stod` all honour `LC_NUMERIC`. Under a
comma-decimal locale the writers emit `1,5`, which makes the bank JSON *structurally invalid*
(an extra separator inside an object) — the whole index fails to parse on the next load. The
readers are honestly fail-closed (all three require whole-token consumption:
`json.cpp:194`, `wire.cpp:127`, `render_settings.cpp:184`), so they degrade to "malformed"
rather than silently truncating — but that means a razor range or a bank field simply
disappears. On Windows the CRT's start-up locale is `"C"` and nothing in this codebase calls
`setlocale`, which is why this has never fired. On Linux the process locale is far more likely
to be set by something else in the address space: SWELL's GDK backend calls `gtk_init_check`
when built with `SWELL_SUPPORT_GTK` (`swell-generic-gdk.cpp:366`; the `#else` branch uses
`gdk_init_check`, `:368`) and **never** calls `gtk_disable_setlocale` (grep over
`vendor/WDL/WDL/swell/` returns no hit), and any GTK/Qt-based plugin loaded into the same
process can do the same.
**Severity: Major** — builds and runs; under a non-C `LC_NUMERIC` the bank index is written
unparseable and the project's whole bank is lost on reload. **Calculus note:** unlike L2-03
(graded Blocker above on zero direct evidence, purely on failure-mode quality), this finding's
mechanism is confirmed by reading the actual writer/reader call sites, and its failure mode —
the entire bank index becomes unparseable — is at least as severe as L2-03's. It is graded only
Major here because it additionally requires a non-C `LC_NUMERIC` in the hosting process, a
likelihood factor L2-03 does not apply to its own claim. Reading both on the same axis (either
both by failure-mode quality, or both by likelihood) would put them closer together than
Blocker/Major suggests; resolving that is a scope call for whoever prioritizes the two, not a
fact this audit can settle statically.
**Effort: M** — two writers and three readers, all in `core/`, all unit-testable; the fix is
a locale-independent path (`std::to_chars`/`std::from_chars`, C++17, or an explicit
`std::locale::classic()`-bound stream), not a `setlocale` call in a plugin.
**Direction.** Make the number codec locale-independent at its two writers and three readers;
add a pure test that pins the emitted text for a fractional value. Do **not** "fix" this by
calling `setlocale` — an extension must not mutate the host's locale.
`[verify — Linux]` — read `LC_NUMERIC` inside a running REAPER-Linux process (e.g.
`ShowConsoleMsg(setlocale(LC_NUMERIC, nullptr))`) before sizing the work.
### L2-05 — prune loses the Recycle Bin *and* the "file is locked" backstop at the same time
**Location:** `src/shell/persist/prune_fs.cpp:167209`; the non-Windows branch is `:200208`.
**Mechanism.** Two Windows properties are load-bearing for the deletion authority and neither
survives. (a) The Windows path routes through `SHFileOperationW` + `FOF_ALLOWUNDO` (`:186188`)
— deletions are recoverable from the Recycle Bin; the `#else` is a hard `fs::remove` (`:203`).
The header already states this honestly (`:3034`, `:162166`). (b) Less obviously, the failure
taxonomy at `:205` (`if (ec) return false; // real failure (locked/permission) -> skip`) encodes
**Windows** file-sharing semantics: a bank file currently open by REAPER's audio engine (an
active `PCM_source`, an item playing from it) cannot be deleted on Windows, so it is counted as
"skipped". On Linux `unlink()` on an open file succeeds — the directory entry vanishes while the
open fd keeps playing, and when the fd closes the bytes are gone with no trash to recover from.
So the Linux prune can delete a file that is *audibly in use*, silently, and the user's only
recovery floor (the superseded-file-survives-until-prune rule, root `CLAUDE.md` §resample bake)
now has nothing under it.
**Severity: Major** — irreversible user-data loss where the Windows build is recoverable; the
"locked" skip branch is dead code on Linux.
**Effort: M** — trash is genuinely non-portable, but the XDG trash spec (`~/.local/share/Trash`
with a `.trashinfo` sidecar) is a self-contained move-plus-metadata write, and the confirm gate
already exists upstream.
**Direction.** Either implement an XDG-trash move in the `#else` (preferred — the deletion
authority is a single ~40-line function and this is exactly where the platform seam belongs), or
make the prune confirmation text platform-aware so a Linux user is told the deletion is
permanent. Do not leave the current silent asymmetry.
### L2-06 — the docked panel never opens on Linux, and fails silently when it doesn't
**Location:** `src/shell/panel/panel_window.cpp:135137`.
**Mechanism.** `CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …)` maps on SWELL to
`SWELL_CreateDialog(SWELL_curmodule_dialogresource_head, (resid), …)`
(`swell-functions.h:616,619`) — it resolves the template out of a per-module registry populated
by the **resgen-generated source**, not out of a linked `.rc`. That generated source is not
currently part of the Linux target (the `target_sources` line is commented out;
build-system detail → §7). The source-side consequence is what matters here: `SWELL_CreateDialog`
returns `nullptr`, `panel_window.cpp:137` does `if (!g_panel.hwnd) return;`, and the toggle
action is a **silent no-op** — no console line, no message box, and `bankPanelIsOpen()` keeps
reporting false so the Actions-list checkmark never lights. The user's only symptom is "the
button does nothing." Note also that `MAKEINTRESOURCE` becomes `((const char*)(UINT_PTR)(x))` on
SWELL (`swell-types.h:1220`), so the id is a pointer-shaped integer — the resgen output must
agree on `IDD_BANK_PANEL == 1000` (`src/resource.h:8`).
**Severity: Major** — the extension loads, every action works, and the primary UI surface is
absent with no diagnostic.
**Effort: S** on the source side (one failure branch), separate from T1's resgen wiring.
**Direction.** Add a one-line `ShowConsoleMsg` on the `!g_panel.hwnd` path naming the missing
dialog resource. That single line converts a mystery into a two-minute diagnosis and is worth
having on Windows too.
### L2-07 — file-drop ingest onto the panel has no opt-in on SWELL
**Location:** `src/shell/panel/panel_window.cpp:145150` (the `DragAcceptFiles` call is
`#ifdef _WIN32`), handler at `:4761,6567`.
**Mechanism.** The comment at `:145147` is accurate — SWELL exposes no `DragAcceptFiles`
(grep over `vendor/WDL/WDL/swell/` finds only `DragQueryFile` and `DragFinish`,
`swell-functions.h:10061007`). What the comment does not say is *how* a drop would arrive
instead. Reading the GDK backend: on a URI-list selection-notify, SWELL resolves the top-level
window, walks down with `ChildWindowFromPoint` (which **is** descending — `for(;;)` loop,
`swell-wnd-generic.cpp:69566981`), and `SendMessage(cw, WM_DROPFILES, (WPARAM)gobj, 0)`
(`swell-generic-gdk.cpp:1622`). The default child proc forwards an *unhandled* `WM_DROPFILES`
up to the parent only when the window *lacks* `WS_EX_ACCEPTFILES`
(`swell-wnd-generic.cpp:7681`) — but that bit's only effect is to **suppress** the up-forward;
it does not enable acceptance, and the message already reaches the window under the pointer via
the `ChildWindowFromPoint` descent regardless of the bit's state. So the panel's own `dlgProc`
plausibly receives the drop **without any opt-in at all**, but the earlier "ex-style bit, not
an API call" framing was backwards about what the bit does. The real SWELL opt-in surface for
`WS_EX_ACCEPTFILES` is the dialog *resource*, not a runtime call: `swell_resgen.pl:10` and
`swell_resgen.php:204` translate a dialog's `WS_EX_ACCEPTFILES` style to
`SWELL_DLG_WS_DROPTARGET`, and `swell-dlg-generic.cpp:320321` sets
`h->m_exstyle |= WS_EX_ACCEPTFILES` from that flag at dialog-creation time. `src/resource.rc:19`
declares `STYLE WS_CHILD` only — no `WS_EX_ACCEPTFILES` — so the bit is off on Linux regardless
of anything `panel_window.cpp` does at runtime. That makes L2-07's real seam `src/resource.rc` +
resgen, the SAME work as L2-06 and §7 bullet 1, not an independent one-liner: a runtime
`SetWindowLong(..., GWL_EXSTYLE, ... | WS_EX_ACCEPTFILES)` would only suppress the
unhandled-drop forward-to-parent — it would not change whether the drop reaches `dlgProc` in
the first place, which already happens via the `ChildWindowFromPoint` descent independent of
the bit. Note also the doc's own conclusion is better-supported than it knew: `src/resource.rc:2122`
is `BEGIN`/`END` — zero child controls — so the GDK `ChildWindowFromPoint` descent has nowhere
to land but the panel HWND itself. Two supporting details still check out: SWELL's
`DragQueryFile` reads the same `DROPFILES` layout the handler assumes
(`swell-wnd-generic.cpp:7730+`), and SWELL's `DragFinish` is a documented no-op ("caller will
free hdrops", `:77257728`) while SWELL itself `GlobalFree`s the handle right after
`SendMessage` (`swell-generic-gdk.cpp:16231624`) — so `panel_window.cpp:59` calling
`DragFinish` is safe, not a double-free.
**Severity: Major `[verify — Linux]`** — if the routing does *not* reach a docked child dialog,
one of the three ingest surfaces (file drop onto the bank panel) is silently dead with no error.
The evidence above says it probably works; it is not proof, and it cannot be exercised until
the panel itself renders (T1's resgen wiring — see L2-06).
**Effort:** no independent source-side fix exists — this is the same resgen work already
costed under L2-06 / §7 bullet 1. Do not add an `Effort: S` line here; adding the
`SetWindowLong` call would compile, do nothing observable, and send an implementer looking for
a bug that isn't where they'd look.
**Direction.** No separate fix. Once the resgen work lands the panel dialog with
`WS_EX_ACCEPTFILES` set from the resource, verify: `[verify — Linux]` drop a WAV onto the
docked panel and confirm `WM_DROPFILES` reaches `dlgProc`.
### L2-08 — the VST3 instrument has no Linux editor, and no automatable parameters to fall back to
**Location:** `src/shell/instrument/editor_platform.cpp:3742` (`isPlatformTypeSupported`
returns `kResultTrue` **only** for `kPlatformTypeHWND`, and only inside `#ifdef _WIN32`);
`:298306` (the non-Windows stubs); `reasampler_editor.h:156269` (the entire paint/input family
is `#ifdef _WIN32`); `reasampler_embed.cpp:135141` (`REAPER_FXEMBED_WM_IS_SUPPORTED` returns 0
off Windows). `reasampler_processor.cpp:360365` still hands the host a `ReaSamplerEditor`.
**Mechanism.** The VST3 SDK *does* support Linux — `kPlatformTypeX11EmbedWindowID`
(`vendor/vst3sdk/pluginterfaces/gui/iplugview.h:79`), `Linux::IRunLoop` with
`registerEventHandler`/`registerTimer` (`:267279`), `Linux::IEventHandler` (`:223`),
`Linux::ITimerHandler` (`:239`), and a `linuxmain.cpp` module entry
(`vendor/vst3sdk/public.sdk/source/main/linuxmain.cpp`). The instrument simply does not
implement any of it. The consequence compounds: with `isPlatformTypeSupported` false for every
type, the host falls back to a **generic parameter UI** — and a grep for
`addParameter|parameters\.add|getParameterCount` across `src/shell/instrument/*.cpp` returns
**nothing**. Zero VST3 parameters are registered as of this branch's base. A Linux user would
get an instrument with no editor and no controls whatsoever. (Phase Γ-W4-T1
`vst3-parameter-set``docs/product/parameter-automation.md` — is landing the parameter set
concurrently, which materially improves this fallback; the *editor* gap is unaffected.)
The specific Win32 dependencies a Linux editor would have to replace, each verified absent from
SWELL: `RegisterClassW`/`CreateWindowExW`/`DefWindowProcW` (`editor_platform.cpp:79103,294`) —
SWELL has no window-class model at all, only `SWELL_CreateDialog` and raw `HWND__` construction;
`MoveWindow` (`:133`) — SWELL has `SetWindowPos` (`swell-functions.h:286`) instead;
`GetWindowLongPtr`/`SetWindowLongPtr` (`:74,105,142`) — SWELL has the non-`Ptr` forms returning
`LONG_PTR` (`:319320`); `TrackMouseEvent`/`WM_MOUSELEAVE` (`:176198`) — SWELL has neither, a
gap the panel layer already documents at `panel_state.h:245246`; `GetKeyState`
(`editor_input_waveform.cpp:38`, `editor_input_curve.cpp:50`) — SWELL has only
`GetAsyncKeyState` (`swell-functions.h:712`); `DragAcceptFiles`/`DragQueryFileW`
(`:109,283`); `GetModuleHandle` (`:75`).
**Severity: Major (instrument only)** — the plugin would load and process audio; it would be
unplayable and uneditable.
**Effort: L** — an X11-embed `IPlugView` (`attachedToParent` receiving an X11 window id rather
than an HWND), an `IRunLoop`-driven timer replacing `SetTimer`/`WM_TIMER`, an event-driven
input path replacing the `wndProc` switch, and a LICE surface bound to that window. This is a
new competence, not a port of the existing one.
**Direction.** Treat as a separate, later decision from the extension (see §3). If it is ever
taken, the natural shape is a small platform seam under `shell/instrument/``editor_platform`
already *is* that seam; it needs a sibling, not a rewrite.
### L2-09 — the draw kit's font faces do not exist on Linux and substitute silently
**Location:** `src/shell/panel/draw_kit.cpp:7077` (`loadFont`), and the two literal faces it is
called with — Segoe UI and Consolas, per `draw_kit.h:6364`.
**Mechanism.** On Linux `CreateFont` goes through fontconfig when built with
`-DSWELL_FONTCONFIG` (gated per the `[verify — Linux]` note in §1.5):
`FcPatternAddString(pat, FC_FAMILY, lfFaceName)``FcConfigSubstitute`/`FcDefaultSubstitute`
`FcFontMatch` (`swell-gdi-lice.cpp:450487`). Whether `FcFontMatch` itself can return failure is
a claim about fontconfig's own internals — fontconfig is not vendored here, so it is dropped
rather than asserted uncited (this doc's own evidence-discipline rule). What the vendored code
does show: even a successful match doesn't guarantee a loadable face —
`swell-gdi-lice.cpp:480484` only sets `face` when `FcPatternGetString(hit, FC_FILE, …)`
resolves to a nonempty string AND the following `FT_New_Face` succeeds; either step can fail,
leaving `face` NULL, and in this build path there is no further fallback — the
`MatchFont`/LiberationSans/DejaVuSans list at `:493539` compiles only in the `#else`
(`#ifndef SWELL_FONTCONFIG`) branch. However `swell-gdi-lice.cpp:400401,561,564` show
`CreateFont` always allocates and returns a non-null `HGDIOBJ__*` regardless of whether `face`
resolved — the failure is recorded internally (`font->typedata = NULL`), not as a null return —
so `draw_kit.cpp:74`'s own `if (!hf) return` guard does **not** catch this failure mode; `hf`
comes back non-null either way. Whatever degraded outcome exists here comes from
`LICE_CachedFont`'s own handling of a null-`typedata` font (§1.5 already cites real
non-Windows branches in `lice_textnew.cpp:236249,829,10271028,1040`), not from `loadFont`'s
stated guard — `[verify — Linux]`. The two WCAG `static_assert`s (`draw_kit.cpp:5354`) are on
**pixel height and weight**, not on the face, so they still hold regardless.
**Severity: Minor** `[verify — Linux]` — cosmetic at best; whether it degrades further than
wrong metrics (e.g. to no text at all) rests on the unconfirmed downstream null-face handling
above, not on anything this audit can read statically.
**Effort: S** — confirmed against the actual call sites: five total (`draw_kit.cpp:154158`,
four using "Segoe UI", one "Consolas"), each a single string-literal argument. A platform
fallback is a `#ifdef`-guarded literal swap at those five sites, not a new mechanism —
`draw_kit.cpp:69`'s comment ("the face is chosen here so a change is one line") describes one
call's literal, not all five; the S estimate rests on there being only five call sites, not on
that comment's wording.
**Direction.** Add a platform fallback face list at the five `loadFont` call sites
(`draw_kit.cpp:154158`). DejaVu Sans / DejaVu Sans Mono are the safe Linux defaults; SWELL's
own no-fontconfig fallback list names LiberationSans/DejaVuSans and
LiberationMono/DejaVuSansMono (`swell-gdi-lice.cpp:505507`), a reasonable precedent to copy.
This same change fixes macOS (San Francisco / Menlo), one code path.
### L2-10 — OS drag-out on SWELL loses the copy-only mask, the readiness probe, and the outcome
**Location:** `src/shell/actions/drag_out_win.cpp:249282` (the `#else` branch);
`drag_out_win.h:711,2933` already states the first and third honestly.
**Mechanism.** Three separate degradations, all real but none fatal. (a) `DoDragDrop`'s
`DROPEFFECT_COPY`-only mask (`:230`) is a **structural** guarantee that no target can MOVE a
bank file out of the folder; `SWELL_InitiateDragDropOfFileList` takes no effect mask
(`swell-functions.h:1011`), so the guarantee reduces to whatever the GDK drag advertises.
(b) `canInitiateDragOut` degrades to `!paths.empty()` (`:276278`), so the caller's careful
coupling — "do not tear down the internal drag until the OS is known ready"
(`panel_drag.cpp:227244`) — has nothing to check against. (c) `initiateDragOut` returns `true`
unconditionally (`:270`), so the advisory success return is meaningless. The caller ignores it,
so (c) is inert today. Note the call path is already Linux-correct in one important respect:
`handOffToOs` releases capture and resets drag state (`panel_drag.cpp:240241`) *before*
invoking the drag, which matters because SWELL's implementation takes capture on its own hidden
window and spins a nested `SWELL_RunMessageLoop` until capture drops
(`swell-generic-gdk.cpp:35753584`, inside `SWELL_InitiateDragDropOfFileList` — the function
`drag_out_win.cpp:270` actually calls; the body is identical to the sibling
`SWELL_InitiateDragDrop` at `:35313560`, which is where an earlier draft of this citation
pointed) — with a 500 ms no-motion timeout that has no Windows analog.
**Severity: Minor** — the feature works; one safety property becomes conventional rather than
structural.
**Effort: S** — mostly documentation; there is no SWELL surface to restore the mask with.
**Direction.** Leave the implementation; make sure the copy-only invariant's home
(`drag_out_win.h:711`) is the doc a Linux reviewer is pointed at, and treat "MOVE is
structurally impossible" as a Windows-only claim in any future spec text.
### L2-11 — `normalizeSlashes` case-folds on `_WIN32` only, which is right for Linux and wrong for macOS
**Location:** `src/core/capture/capture_paths.cpp:1820`.
**Mechanism.** The one platform conditional in `core/`. Linux filesystems are case-sensitive, so
*not* folding is correct and the existing tests already assert it
(`tests/test_capture_paths.cpp:5051`). The shared-path note: macOS's default APFS/HFS+ is
case-**insensitive**, so the same `#ifdef _WIN32` under-folds there — a pre-existing macOS
defect this audit surfaces but does not own.
**Severity: Minor** (no Linux defect). **Effort: S** — confirmed: the one platform conditional
in `core/` is a single `#ifdef _WIN32` block (`capture_paths.cpp:1820`) with no other call
site depending on the token; a predicate swap is a one-block change.
**Direction.** No Linux action. If macOS is ever targeted, the predicate wants to be
"case-insensitive filesystem", not "Windows".
### L2-12 — `fs::path::string()` narrowing is strictly better on Linux (informational)
**Location:** `prune_fs.cpp:118`, `ingest.cpp:222,254`, `capture_paths.cpp:95`,
`panel_bank_ops.cpp:39`, `scope_resolve.cpp:207`, `insert.cpp:71`,
`capture_realtime_shell.cpp:306`, `capture.cpp:408`, `instrument_bake.cpp:166`.
**Mechanism.** On MSVC `std::filesystem::path::string()` narrows through the active code page;
on Linux `path` is already `char`-based, so `.string()` is a byte passthrough and UTF-8 survives.
The one site that deliberately works around the Windows behaviour —
`instrument_drop_win.cpp:123128`, using `u8string()` — is harmless on Linux (C++17, so
`u8string()` returns `std::string`; `CMakeLists.txt:28` pins `CMAKE_CXX_STANDARD 17`, and under
C++20 the `.c_str()` at `:128` would become a `const char8_t*` type error — worth knowing before
anyone bumps the standard).
**Severity: Minor / informational.** **Effort: none.** **Direction:** no action; do not "fix" it.
---
## Surfaces checked and found clean
Recorded so the sweep's negative results are as auditable as its findings.
- **Missing standard includes** (the MSVC-transitively-provides class). Ran a use-vs-include diff
across all 293 files for `<cstring> <cstdio> <memory> <atomic> <algorithm> <limits> <cmath>
<functional> <ctime> <cstdlib>`. Five raw hits; all five verified false positives on read —
four were the symbol name appearing in a *comment* (`render_settings.h:53`,
`ext_state_read.h:20`, `action_registry.h:7`) or a correct `<cmath>` `std::abs`
(`velocity_curve.cpp:229`, `<cmath>` at `:6`), and `processor_reload.cpp`'s `std::unique_ptr`
comes from `reasampler_processor.h:12`. **No missing include found — but this only covers the
ten headers above.** The two headers most likely to produce the classic
MSVC-transitively-provides failure under libstdc++, `<vector>` and `<string>`, were excluded
from that sweep; re-run including them: **45 files use `std::vector` without `#include
<vector>`, and 52 use `std::string` without `#include <string>`** (raw grep-diff counts,
unread). `<map>` and `<set>` add 2 and 3 more respectively. These 102 raw hits were **not**
individually verified the way the five above were (that would mean reading 102 files); the
claim this sweep actually supports is "up to 102 files may rely on transitive inclusion for
`<vector>`/`<string>`/`<map>`/`<set>`, unconfirmed one by one" — not "no missing include
found." The two header cases that matter most for the extension's own build
(`shell/capture/capture_orchestrator.h`, `src/ext_keys.h`) are confirmed satisfied
transitively.
- **Templates / two-phase lookup.** Exactly 7 templates in the tree
(`core/wire/bytes.h:25,61`, `core/wire/ext_state_read.h:37`,
`core/instrument/engine/play_params.h:191,203,208,218`). None derives from a dependent base,
none calls an unqualified dependent name, none needs `typename`/`template` disambiguation.
GCC/Clang-safe as written.
- **Compiler extensions.** Zero `#pragma` other than `#pragma once`; zero `__declspec`,
`__attribute__`, `__forceinline`, anonymous struct/union, or MSVC-permissive construct in
`src/**`.
- **`HWND__` forward declaration.** `drag_out_win.h:19` declares `struct HWND__;`; SWELL declares
`typedef struct HWND__ *HWND;` (`swell-types.h:211`) — same tag, no `-Wmismatched-tags`.
- **`preview_register_t` platform fork.** `panel_audition.cpp:3641,5863` and
`panel_state.h:2024` match the SDK's own fork exactly (`CRITICAL_SECTION cs` on `_WIN32`,
`pthread_mutex_t mutex` otherwise — `vendor/reaper-sdk/sdk/reaper_plugin.h:13081312`).
`<pthread.h>` is included on the non-Windows branch. Correct.
- **Prompts and message boxes.** Every user prompt goes through REAPER's own
`GetUserInputs`/`ShowMessageBox`/`ShowConsoleMsg` (`panel_bank_ops.cpp:2425,81,100,126,381`),
never a Win32 `MessageBox`. Cross-platform by construction.
- **Menus.** `CreatePopupMenu`/`InsertMenu`/`TrackPopupMenu(TPM_RETURNCMD)`/`DestroyMenu` are all
SWELL-provided (`swell-functions.h:528532`, and `TrackPopupMenu`/`DestroyMenu`/
`CreatePopupMenu` entries); the "pos < 0 appends" assumption at `panel_bank_ops.cpp:226` matches
SWELL's `SWELL_InsertMenu`. The comment at `:220222` is accurate.
- **Keyboard/accelerator path.** `accelerator_register_t` + `MSG`/`WM_KEYDOWN` + `GetFocus` +
`GetParent` (`panel_input.cpp:503521`) are all SWELL-provided; every `VK_*` used is in
`swell-types.h`.
- **Double-buffered paint.** `LICE_SysBitmap` + `getDC()` + `BitBlt(SRCCOPY)`
(`panel_render.cpp:474,549`) — `LICE_SysBitmap` has real non-Windows paths
(`lice.cpp:179`, `lice.h:362`), `BitBlt`/`SRCCOPY` are SWELL-provided.
- **Byte order.** Handled explicitly everywhere it matters (see §1.1). No native-integer
`memcpy` onto a wire buffer anywhere in `core/wire`.
- **Hot-path guardrails.** Nothing in this audit's remediation directions touches
`peaks::computeEnvelope` (still a free function), the audition call-through
(`panel_audition.cpp` — direct calls, no interface), or the realtime tick's single-pointer-test
idle path (`main.cpp:156`). L2-04's number-codec change is on the JSON/persist path, which root
`CLAUDE.md` explicitly declares off all hot paths. **No recommendation here adds a hot-path
indirection.**
---
## 3. Extension vs. instrument — the cost split
The two artifacts are cleanly separable, and the evidence says the split is very lopsided.
### 3.1 Extension only (`reaper_reasampler`)
**What must change in source:** two compile Blockers (L2-01, L2-02 — both one-line), one load
hazard (L2-03), one silent-failure diagnostic (L2-06), one correctness/safety fix (L2-05), one
data-integrity fix (L2-04), and cosmetics (L2-09). Nothing here is architectural. The panel's
whole SWELL/LICE surface — dialog lifecycle, docking, `WM_PAINT` double-buffered LICE draw,
mouse/wheel/capture, cursors, menus, keyboard accelerator, modifier keys, tooltips, drag-out,
drop-in — was checked call-by-call against the vendor headers and is **already SWELL-portable**;
see §1.5 and "Surfaces checked". This is the single most load-bearing finding of the audit: the
panel was written against SWELL's vocabulary throughout, and the Windows-only escapes are three
small ones (`DragAcceptFiles`, `SHFileOperationW`, OLE `DoDragDrop`) that each already carry a
non-Windows branch or a documented reason they do not.
**Estimate shape:** S+S+S+S+M+M+S. No L item.
### 3.2 Instrument additionally (`reasampler_vst`)
**What must be written from nothing:** an X11-embed `IPlugView` (window creation and parenting
without `RegisterClass`/`CreateWindowEx`), an `IRunLoop`/`ITimerHandler`-driven replacement for
the `SetTimer`/`WM_TIMER` sync poll, an event-driven input path replacing the entire `wndProc`
switch (~150 lines of `editor_platform.cpp:139296`), hover-leave detection without
`TrackMouseEvent`, modifier reads via `GetAsyncKeyState` instead of `GetKeyState`, and drop-accept
without `DragAcceptFiles`. The paint side is the cheapest part — every painter already draws into
a `LICE_IBitmap` through the shared kit (`editor_paint.cpp:28,51`), so the drawing survives a
window-system change intact; it is the *window and event plumbing* that is entirely absent. The
TCP/MCP embed strip (`reasampler_embed.cpp:135141`) is a smaller, separate 0→1
(`REAPER_FXEMBED_WM_IS_SUPPORTED` currently returns 0 off Windows; the paint body at `:174+` is
LICE and would port).
**Estimate shape:** one L, and it is a genuinely new competence rather than a port.
### 3.3 The consequence for sequencing
The extension is a Linux target that is largely *already met* and needs a handful of small,
well-localized fixes. The instrument is a separate project. The two share exactly one file —
`shell/panel/draw_kit` — and that file's only Linux blocker is L2-01. Daniel rules on whether the
instrument is in scope at all; the evidence says the extension does not wait on it.
---
## 4. Feature-degradation list — what builds but behaves differently, or not at all
Blunt column: **GONE** = the capability does not exist on Linux; **DIFFERENT** = it exists with
changed behaviour; **AT RISK** = depends on an unverified assumption.
| Feature | Verdict | What actually happens | Cite |
|---|---|---|---|
| Prune → Recycle Bin | **GONE** | Hard `unlink`, unrecoverable | `prune_fs.cpp:200208` |
| Prune's "file is locked" skip | **GONE** | `unlink` on an in-use file succeeds; the skip branch is dead code | `prune_fs.cpp:205` |
| Docked bank panel | **GONE (today)** | `SWELL_CreateDialog` returns null; toggle is a silent no-op until the resgen source is added (§7) | `panel_window.cpp:135137` |
| VST3 editor | **GONE** | `isPlatformTypeSupported` false for every type; host shows a generic UI | `editor_platform.cpp:3742` |
| VST3 generic-UI fallback | **GONE (today)** | Zero parameters registered as of this base; Phase Γ-W4-T1 changes this | grep `addParameter` over `shell/instrument/*.cpp` → no hits |
| TCP/MCP embed strip | **GONE** | `REAPER_FXEMBED_WM_IS_SUPPORTED` returns 0 | `reasampler_embed.cpp:135141` |
| Kit fonts (Segoe UI / Consolas) | **DIFFERENT** | fontconfig substitutes silently; metrics and ellipsis points shift; ValueMono may lose tabular alignment | `swell-gdi-lice.cpp:450487` |
| OS drag-out copy-only guarantee | **DIFFERENT** | No effect mask; copy-only is conventional not structural | `drag_out_win.cpp:257270` |
| OS drag-out readiness/outcome | **DIFFERENT** | Probe degrades to "non-empty"; return is always `true`; 500 ms no-motion timeout has no Windows analog | `drag_out_win.cpp:276278`, `swell-generic-gdk.cpp:35793580` |
| Panel file-drop ingest | **AT RISK** | Depends on SWELL delivering `WM_DROPFILES` without the Win32 opt-in | `panel_window.cpp:145150` |
| Bank index float round-trip | **AT RISK** | Unparseable under a non-C `LC_NUMERIC` | `json.cpp:3943,190196` |
| Extension load | **AT RISK** | Any one unresolved API name in ~869 = silent refusal | `main.cpp:292293` |
| Path case sensitivity | **DIFFERENT (correct)** | No case-fold; correct for Linux, and the tests already assert it | `capture_paths.cpp:1820` |
| Prune reclaim on a symlinked bank file | **AT RISK** | `fs::directory_iterator` + `is_regular_file()` follows symlinks (C++17); size is read from the target via `file_size()` but `fs::remove` deletes the link, not the target — prune reports N bytes reclaimed and reclaims zero. Symlinked media folders are far more idiomatic on Linux than Windows. | `prune_fs.cpp:113124` |
| Audition / preview | **INTACT** | `preview_register_t` fork matches the SDK; `PlayPreview`/`StopPreview` are REAPER API | `panel_audition.cpp:3663` |
| Docking, menus, cursors, keyboard, wheel, capture rollback, tooltips | **INTACT** | All SWELL-provided; verified call-by-call | §1.5 |
| Capture pillar (offline + realtime), persist, tracking, prune *computation* | **INTACT** | Pure `core/` + REAPER API only | §1.1 |
---
## 5. Open questions
### `[verify — Linux]` — answerable only on a Linux box
1. **Process locale.** Read `setlocale(LC_NUMERIC, nullptr)` inside a running REAPER-Linux
process (print via `ShowConsoleMsg`) on a machine whose user locale uses a decimal comma.
Decides whether L2-04 is urgent or latent.
2. **REAPER API completeness.** Instrument `main.cpp:292` to print
`REAPERAPI_LoadAPI`'s return value instead of discarding it, load once, and record the count.
Non-zero decides whether L2-03 is a real Blocker and names the gap.
3. **Panel file-drop routing.** With the panel docked, drag a WAV from the file manager onto it
and observe whether `dlgProc` sees `WM_DROPFILES` (L2-07). If not, add `WS_EX_ACCEPTFILES`.
4. **Which SWELL GDI/locale build REAPER ships.** Whether REAPER's `libSwell.so` is built with
`SWELL_SUPPORT_GTK` (→ `gtk_init_check`, `swell-generic-gdk.cpp:366`) or without
(→ `gdk_init_check`, `:368`) changes the likelihood in (1). Observable indirectly via (1).
5. **fontconfig substitution for "Consolas".** Whether it lands on a monospaced face on a stock
distro decides whether L2-09 is cosmetic or a real readability regression on numeric readouts.
6. **`SWELL_InitiateDragDropOfFileList` acceptance semantics.** Whether a GDK-initiated file drag
is accepted as a copy by common targets (a file manager, another DAW), and whether the 500 ms
no-motion timeout (`swell-generic-gdk.cpp:35793580`) cancels a slow user gesture.
7. **Prune against an in-use file.** Play an item from a bank file, prune it, confirm the audio
survives until the fd closes and the file is then unrecoverable — the concrete demonstration
behind L2-05(b).
### `[Daniel]` — scope decisions, not knowledge gaps
1. **Is the VST3 instrument in scope for Linux at all?** §3 supplies the cost split; the
extension does not depend on the answer. This is the phase's one real fork.
2. **Is a hard `unlink` an acceptable prune on Linux**, with a platform-aware confirmation
string — or must XDG trash be implemented before Linux ships? (L2-05.)
3. **Is silent font substitution acceptable**, or does the kit get an explicit Linux face list?
(L2-09.)
4. **Does "copy-only is structural" survive as a shipped invariant** when one platform can only
offer it conventionally? (L2-10.)
---
## 6. Ordering sketch
Dependency order only — this is not a plan, and it assigns no waves.
1. **Make it compile.** L2-01 (`FF_DONTCARE`), L2-02 (`GetCurrentProcessId`). Nothing else can
be observed until these land; both are one-line and independent of each other.
2. **Make it load, visibly.** L2-03 (`REAPERAPI_LoadAPI` diagnostic or `REAPERAPI_MINIMAL`) —
must precede any runtime verification, because it is the failure mode that produces no
evidence. Pairs naturally with `[verify — Linux]` (2).
3. **Make the panel appear.** L2-06 (the missing-dialog diagnostic) alongside T1's resgen wiring.
The diagnostic should land first so the resgen step can be confirmed rather than assumed.
4. **Then, and only then, verify.** `[verify — Linux]` items 1, 3, 5, 6, 7 all need a running
panel. Item 2 is already answered by step 2. **The critical path for this step runs through
the parallel build-system track, not this one:** "a running panel" means T1's resgen wiring
(§7 bullet 1) has landed — this doc doesn't own that work and doesn't know its schedule, so
step 3's "alongside T1's resgen wiring" is doing real scheduling work, not just sequencing
flavor. Anyone driving this ordering sketch needs to coordinate with T1 before step 4 can
start, not just after step 3 finishes on this track's own items.
5. **Data integrity.** L2-04 (locale-independent number codec) — pure `core/` work, unit-testable
on Windows, and it should land before any Linux user saves a project. Sequenced after the
verify only so its urgency is known; the *work* is not blocked.
6. **Deletion safety.** L2-05 (XDG trash or a platform-aware confirm) — gated on `[Daniel]` (2).
7. **Ingest opt-in, if needed.** L2-07 — no independent fix; it is the resgen work already
sequenced in step 3, strictly gated on `[verify — Linux]` (3) to confirm it's needed at all.
8. **Cosmetics.** L2-09 (fonts), L2-10 (drag-out doc). Independent of everything above.
9. **Separately, if ruled in.** L2-08 — the instrument's X11 editor. No dependency in either
direction on 18 except L2-01, which the shared `draw_kit` needs regardless.
---
## 7. Referred to T1 (build-system; spotted, not analyzed)
- **SWELL dialog resgen is not wired for Linux.** `src/app/CMakeLists.txt:97` — the
`target_sources(... resource.rc_mac_dlg.h)` line is commented out in the Linux `else()` branch
(and in the `APPLE` branch, `:86`). This is the mechanical cause of L2-06.
- **VST3 target is `if(WIN32 …)`-gated.** `src/shell/instrument/CMakeLists.txt:9` — the target is
not configured at all off Windows; `dllmain.cpp` is hard-coded at `:78` where Linux needs
`linuxmain.cpp` (`vendor/vst3sdk/public.sdk/source/main/linuxmain.cpp` exists).
- **`SHCreateStdEnumFmtEtc` / `SHFileOperationW` / `OleInitialize` need shell32/ole32.** Only on
the Windows branch, but worth confirming the Linux branch links none of them.
- **`reaper_plugin.h`'s relative SWELL include.** `vendor/reaper-sdk/sdk/reaper_plugin.h:49`
does `#include "../WDL/swell/swell.h"`, which does not exist under `vendor/reaper-sdk/`. It
resolves only because `${WDL_INC}` is `vendor/WDL/WDL` and `vendor/WDL/WDL/../WDL/swell/swell.h`
is the same file. Works, but it is a coincidence worth knowing before anyone moves an include
path.
- **`<filesystem>` link requirement.** GCC < 9 needs `-lstdc++fs`; `core/` and `shell/` both use
`std::filesystem` heavily.
- **LICE TU set.** `CMakeLists.txt:7982` adds `lice.cpp`, `lice_line.cpp`, `lice_arc.cpp`,
`lice_textnew.cpp` unconditionally. All four have non-Windows paths, but the Linux link needs
them compiled against `SWELL_PROVIDED_BY_APP` consistently with the module.
---
## Summary table
| ID | Finding | Severity | Effort | Artifact |
|-------|-----------------------------------------------------------------|----------|--------|--------------|
| L2-01 | `FF_DONTCARE` undefined off Windows (`draw_kit.cpp:73`) | Blocker | S | both |
| L2-02 | `GetCurrentProcessId()` unguarded, absent from SWELL | Blocker | S | extension |
| L2-03 | `REAPERAPI_LoadAPI` all-or-nothing over ~869 names, silent | Blocker `[verify]` | S | extension |
| L2-04 | Persisted floats are `LC_NUMERIC`-dependent, both directions | Major | M | both |
| L2-05 | Prune loses trash AND the locked-file backstop | Major | M | extension |
| L2-06 | Panel dialog never created; silent no-op | Major | S | extension |
| L2-07 | No drop-accept opt-in on SWELL; real seam is resgen, same as L2-06 | Major `[verify]` | — (see L2-06) | extension |
| L2-08 | No Linux VST3 editor; no parameters to fall back to | Major | L | instrument |
| L2-09 | Segoe UI / Consolas substitute silently via fontconfig | Minor | S | both |
| L2-10 | SWELL drag-out: no copy mask, no probe, no outcome | Minor | S | extension |
| L2-11 | Case-fold is `_WIN32`-only (right for Linux, wrong for macOS) | Minor | S | both |
| L2-12 | `fs::path::string()` narrowing — Linux is strictly better | Informational | — | both |
+773
View File
@@ -0,0 +1,773 @@
# Bank package — product notes
Framing, rationale, and design-direction calls behind **Phase Ε — bank export and
import as a single-file package**. The tickable spec lives in `docs/PLAN.md`
(§Phase Ε); the architecture detail will live in `src/core/package/CLAUDE.md` and
`src/shell/package/CLAUDE.md` once those directories exist. This doc holds the
*why* — the user problem, the container choice, the version-compatibility policy
and the reasoning that produced it, the failure-mode table, and what a package
deliberately does not carry.
Status: framed by product-designer (2026-08-02); **all three [Daniel]-class forks
RULED the same day** — Ε-F1 **proprietary container** (`RSBK`), Ε-F2 **import always
lands as a new bank**, with an automatic suffix on a name collision, Ε-F3 **refuse an
import while the tracking ledger is degraded**. See §"Rulings" for the index and the
recorded rationale; each is specified in place in the section that owns it. Nothing in
this doc is open. Everything else below is a product-designer call with its reasoning
stated; contradict it in review with an argument, not a preference.
---
## What it is (and what it is not)
**A bank package is one file that carries one bank — its audio and its index —
out of a project and into another.** Today a bank is per-project by construction:
the audio sits in `<projectDir>/reasampler_bank/` (`core/capture/capture_paths.h`'s
`kBankSubfolder`) and the index that gives that audio meaning lives in the `.rpp`'s
project ext state under the `"reasampler"` namespace (`src/ext_keys.h`'s
`kProjExtBanksKey`). The two travel together with the project and nowhere else.
Export writes both halves into a single `.rsbank` file; import lands them into
another project's bank folder and index.
**It is not a project-transfer feature.** REAPER already moves projects — *Save
project as… with copy of media*, track templates, subprojects. None of them can
carry a ReaSampler bank, because none of them knows the ext-state index exists;
copy the `reasampler_bank/` folder by hand into another project and you get a pile
of `.wav` files with no names, no loop points, no root notes, no tempo stamps, no
tiers, and no lineage. The package exists precisely because **the metadata is the
part that cannot be moved by hand.**
**It is not a preset.** A package carries audio plus bank metadata. It does not
carry ReaSampler 9000's dialed sound — filter, envelopes, splines, loop crossfade,
rate, pitch. That is the instrument's `ComponentState`, and a user who wants the
dialed sound in another project bakes it first (Phase Ξ's resample) and exports the
resulting capture. The package is a *bank*, and the bank has always been the audio,
not the instrument. See "What a package deliberately does not carry" below — this
is the most likely user expectation mismatch in the whole feature, so it is headed
off here rather than discovered in a support thread.
**It is not a re-encode.** Sample bytes leave the source project and arrive at the
destination byte-identical. Frame count, sample rate, bit depth, channel count are
untouched; no trim, no normalize, no mono collapse, no format conversion, no
compression of the audio payload. The package payload is **opaque bytes** to
everything in the export/import path except a hash function. This is the phase's
trust anchor, and it is the direct analogue of the capture pillar's null test.
---
## Why a single file, not a folder copy
The obvious cheap alternative is "copy the bank folder, and write the index into a
sidecar JSON beside it." Rejected, for four reasons, in descending order of weight:
1. **A folder has no place to put its own manifest that a user cannot lose.** The
index is the part that makes the audio a bank. In a folder, the manifest is just
one more file among two hundred `.wav`s — droppable, renamable, editable into
inconsistency, and silently absent after a partial copy. In a single file it is
the header, and the file either has one or is not a package.
2. **Integrity and version tagging need one identity.** "Is this package complete,
and can this build read it?" is answerable in one read of one file's first few
kilobytes. A folder answers it only after enumerating and stat-ing every entry,
and answers "was anything edited since export?" not at all.
3. **The move gesture is one object.** Email it, drop it in shared storage, drop it
on the docked panel. The panel already accepts `WM_DROPFILES` for ingest
(`src/shell/panel/panel_window.cpp` header comment: "WM_DROPFILES -> ingest"), so
a package can ride an affordance that exists.
4. **Atomicity is buyable.** A single file can be written to a temp path and
atomically renamed on success — the precedent the mono collapse already set
(Ψ-W2-T2 landed the collapse "via temp file plus atomic rename"). A half-written
folder looks exactly like a complete one.
The counter-argument for the folder is real and should be recorded: a folder is
inspectable with no tooling. The container ruling below does **not** buy that back —
`RSBK` is opaque without our tool — so the inspectability loss is an accepted cost,
paid deliberately, not an oversight to be corrected later by reaching for ZIP.
---
## The container — a proprietary `RSBK` (Ε-F1, RULED)
**Ruled by Daniel, 2026-08-02: *"proprietary container."*** The package is a
hand-rolled `RSBK` file. ZIP — whether via the vendored MiniZip64 in
`vendor/WDL/WDL/zlib/` or as a hand-written stored-only ZIP *shape* — is rejected and
is not to be revisited inside this phase.
**The shape.** Magic `RSBK`, a fixed little-endian header carrying the two version
fields (§"Version tagging" below), a length-prefixed JSON manifest, then each entry's
payload concatenated in manifest order. Framing overhead is tens of bytes, not
kilobytes.
**What it reuses, rather than invents.** The little-endian byte codec
(`core/wire/bytes.h``putLE` / `ByteReader`, called out in `src/core/wire/CLAUDE.md`
as the earned template case) and the hand-rolled JSON layer (`core/json`). Both are
already owned and already tested here.
**Why the ruling went this way.** The load-bearing reason is not effort — it is that
the pure/shell split is this project's central discipline, and `RSBK` is the only
candidate where the **whole codec lands pure** and the shell is a bytes-in/bytes-out
skin. MiniZip's API is path-and-file-handle shaped (`ioapi.h`), so a ZIP codec drags
the filesystem into the layer that discipline keeps free of hosts; a buffer-backed
`zlib_filefunc_def` is possible but fiddly and defeats the "standard format" argument
inside the code even while preserving it on disk. Compression buys almost nothing on
float32 PCM. Writing a correct ZIP central directory by hand was the worst of both —
more code than `RSBK`, the same hardening burden, plus Zip64 and name-encoding edge
cases, and still no compression.
**The two costs, accepted with the ruling.** (1) The package is **opaque without our
tool** — no unzip-and-look support path. (2) We own the hostile-input hardening of our
own parser, to the discipline `bank_model::deserialize` and `parseLedger` already
carry — *error signaled, never UB* (`bank_model.h`'s `BankModel::deserialize`). Both are priced in; a
later "let's make it inspectable" impulse is a new phase's argument, not this one's.
**This was a one-way door and it is now shut** — packages are in users' hands the day
it ships, and a later container change means either a second reader forever or
stranded packages. The version ladder below, not a format swap, is how the format
moves from here.
---
## Version tagging: two questions, and why one number cannot answer both
### The precedent this extends (read from source, 2026-08-02)
The repo already carries **two** versioning mechanisms, and they answer different
questions:
1. **A blob-schema ladder.** `src/core/tracking/origin_ledger.cpp`'s version-ladder
header comment states the
ladder for the `owned_files` blob (v1 legacy path-only, v2
current), pins `constexpr int kLedgerVersion = 2`, and — the load-bearing part —
**reads and validates `"v"`, not merely writes it**: "A version above
`kLedgerVersion` is therefore its own degraded status, never a Loaded ledger"
(the same comment). The parse outcome is a three-way `Ok` / `Malformed` / `FutureVersion`
(`origin_ledger.cpp`'s `ParseOutcome` enum and `parseStored`), deliberately distinguished so the operator gets the right
recovery advice. A *field-vocabulary* gap behaves oppositely: an unrecognized
`OriginKind` integer degrades to `Unknown` rather than failing the parse
(`origin_ledger.cpp`'s `kindFromInt`), because "a vocabulary gap must not halt the prune"
(`src/core/tracking/CLAUDE.md` §"Gotchas").
2. **An app writing-version stamp.** `src/core/version/app_version.h`'s
`WritingVersion` with `PreVersioning` / `Unknown` / `Stamped`, classified by
`classifyWritingVersion`, stamped into project ext state by
`ReaSamplerSession::saveToActiveProject` (`src/shell/persist/ext_state_io.cpp`) using `stampVersion()` (the numeric
triple only, no channel suffix). It is informational: an absent stamp is "not an
error and not a warning" (`app_version.h`'s `WritingVersion` comment, the `PreVersioning` case).
**An observation worth recording, not a defect to fix here:** `BankBook` writes
`"version": 1` into the banks blob (`src/core/model/bank_book_json.cpp`'s `BankBook::serialize`) but its
parser skips the key along with every other unknown one
(`bank_book_json.cpp`'s `parseBook``if (!r.skipValue()) return false; // version, or unknown`).
The book's version field is therefore **decorative today** — written, never read,
never gating. The ledger's is the precedent to extend; the book's is the precedent
not to repeat.
### The two questions a package must answer
- **"Can I parse this shape at all?"** — a hard gate. Monotonic integer. This is
`origin_ledger`'s `"v"`.
- **"Who wrote this, so I can tell the user what to open it with?"** — informational,
never a gate. Semver string. This is `app_version`'s stamp.
A package carries **both**, and conflating them is the mistake to avoid. The stamp
alone cannot gate (semver ordering does not track schema shape; a patch release can
change a blob and a minor release can leave it alone). The ladder alone cannot
advise (an integer tells a user nothing about which build to install).
### The refinement: `formatVersion` **and** `minReaderVersion`
A single ladder has one bad property: **every change strands every older reader,
even a purely additive one.** That is not hypothetical here — look at what `Sample`
has actually accumulated: `rootNote` and `loop` (`bank_model.h`'s `Sample::rootNote` / `Sample::loop`,
"additive like `provenance`. Both default cleanly empty"), `captureTimeSigNum` /
`captureTimeSigDenom` (`Sample::captureTimeSigNum` / `Sample::captureTimeSigDenom`, "0/0 means UNSTAMPED"), `channelCount`
(`Sample::channelCount`, "0 = unknown — a pre-field entry"). Every one of those was additive with
a defined absent-value. Under a single ladder, each would have blocked older readers
for no reason.
So the package header carries two integers:
- **`formatVersion`** — what this writer emitted. Monotonic, bumped on any change.
- **`minReaderVersion`** — the oldest reader that can read this package *safely*.
Bumped only when a change is **structural** (a field's meaning changes, a section
is removed, framing changes); left alone when a change is **additive** (a new
optional manifest key, a new `Sample` field with a defined absent-value — exactly
the four listed above).
The reader's rule is one line: **read it iff
`minReaderVersion <= kPackageFormatVersion`.** `formatVersion` is then only for the
message text and the log.
**One change class that looks additive and is not: a new enum value.**
`BankModel::deserialize` *rejects* an out-of-range `SourceMode` or `Tier` rather than
degrading it (`bank_model.cpp`'s `parseSample` — the `sourceMode` and `tier` branches), and every enum a package carries
rides inside the nested `BankModel` blob. So growing either vocabulary is
**structural** and bumps `minReaderVersion` too. This is wider than packages and
predates them: `BankModel::deserialize` is also the live project ext-state parser
(`bank_book_json.cpp`'s `parseBank`), so appending a `SourceMode` value already strands an older
build opening a newer project's `.rpp`. Phase Ε inherits that property; it did not
cause it, and changing it — degrade-to-`Unknown` at those two sites, the way
`BakeStatus` already does — is a change to the model layer, not a package concern. It
leaves the argument above untouched: the four fields that motivated the two-integer
design are *fields*, and `parseSample`'s `skipValue()` fallback
(`bank_model.cpp`), plus the manifest parsers' equivalent at each level, still
carries them forward.
This is a borrowed pattern, not an invention: Matroska's `EBMLVersion` /
`EBMLReadVersion` pair, PDF's catalog `/Version` over the header version, and OOXML's
`mc:Ignorable` markup-compatibility mechanism all separate "what I am" from "what you
must understand to read me." It costs one extra integer and one writer discipline —
*decide honestly whether your change is additive* — and that discipline is exactly
the one `origin_ledger` already enforces on `OriginKind`
(`src/core/tracking/CLAUDE.md` §"Gotchas": "PERSISTED INTEGERS — never renumber, only
append").
### Both directions, concretely
**Direction 1 — newer ReaSampler, older package. Always imports. Never refuses.**
Every reader reads every `minReaderVersion <= kPackageFormatVersion`. Absent manifest
keys take their defined defaults, exactly as `Sample`'s additive fields already do,
and exactly as `origin_ledger` lifts a v1 path-only blob into v2 records with kind
`Unknown` and empty ids (`origin_ledger.cpp`'s version-ladder header comment). Unrecognized manifest keys are
skipped, which is already how every parser in this repo behaves
(`bank_book_json.cpp`'s `parseBook`). Unrecognized enum integers (the manifest's own —
`BankModel`'s nested ones reject) degrade to their defined `Unknown`-equivalent,
never to the numeric default and never to a parse failure —
`bake_wire`'s rule verbatim (`src/core/wire/CLAUDE.md` §"Modules", the `bake_wire` bullet: "an unrecognized value
decodes as `Failed` rather than as the numeric default `Ok`").
**The user sees:** a normal import summary. Optionally a single console line naming
the older writer version. No dialog, no warning, no ceremony — a supported case is
not an incident.
**Direction 2 — older ReaSampler, newer package. Refuses. Whole-package, nothing
written.** `minReaderVersion > kPackageFormatVersion` is a hard stop, before a single
byte is written to the bank folder and before the index is touched. This is exactly
`LedgerStatus::FutureVersion`'s treatment, and for the same reason stated at
`origin_ledger.cpp`'s version-ladder header comment: parsing an unknown shape by old rules "would yield a
plausible-but-partial" result, and a partial bank is worse than no bank.
**The user sees** a message box (`ShowMessageBox`, verified —
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:6546`,
`int (*ShowMessageBox)(const char* msg, const char* title, int type)`) naming three
things, because any two of them leave the user stuck:
> **Cannot import this bank package.**
> It was written by ReaSampler 1.7.0 and needs package format 3 or newer.
> This build (1.5.2) reads package format 2.
> Nothing was imported. Install ReaSampler 1.7.0 or newer and try again.
The writer's semver is what makes the message *actionable* — "format 3" alone tells a
user nothing they can act on. That is the whole reason both fields exist.
**Refusing is the correct direction to refuse in**, and it is worth saying why
rather than leaving it as taste: the destination project is the user's existing work.
A refusal costs a transfer the user can retry after updating. A best-effort partial
import costs silent data absence inside a project they will keep working in, and they
will not find out which twelve of forty samples were dropped until they need one.
---
## What a package carries
- **The two version fields and the writer's semver**, in the fixed header.
- **An export timestamp** and the **source bank's display name** — informational, and
the default the import prompt pre-fills.
- **One manifest entry per sample**, carrying that `Sample` record in
**`bank_model`'s own serialization, nested verbatim**. This is the
`bank_book_json` precedent applied outward: the book writer "emits the bank
envelope … plus a raw `index` member whose value is the `BankModel` blob verbatim,
so per-bank sample serialization stays owned by `bank_model` and is not duplicated
here" (`bank_book_json.cpp`'s file-header comment). The package does the same, so a future `Sample`
field reaches packages for free and the shape has exactly one owner.
- **Per entry, additionally:** the payload's **bare file name** inside the package,
its byte length, and a whole-file `hashBytes` digest
(`core/capture/wav_codec.h`'s `hashBytes` — FNV-1a 64-bit over raw bytes, 16-char lowercase
hex). Note carefully: `hashBytes`, **not** `hashWavContent`. The latter deliberately
skips non-`fmt `/`data` chunks (`wav_codec.h`'s `hashWavContent`), which is right for dedup
identity and wrong for "did these bytes survive the trip." Both hashes are already
in the codebase; the package needs the raw one for integrity and carries the
`Sample`'s existing `contentHash` for dedup, and they are different fields
answering different questions.
- **The bank's slot map** — display positions (`core/model/slot_map`), already JSON
round-trippable. A bank's arrangement is part of what the user built.
- **The payloads**, byte-exact, in manifest order.
`hashBytes` is FNV-1a — a corruption detector, not a cryptographic checksum. Say so
plainly in the code and in any user-facing wording: it catches truncation, bit rot,
and a mangled transfer. It does not certify provenance, and it is not a defense
against a package deliberately crafted to collide. That is the right level of
guarantee for this feature; overselling it would be the error.
## What a package deliberately does NOT carry
- **Any absolute path. Any path at all.** Entries are **bare file names** — no
directory component, no `..`, no drive letter, no leading separator — validated on
encode *and* on decode. The importer spells the destination path itself, through
the same `capture_paths` arithmetic every capture already uses. This makes the
relative-paths-only precision invariant **structural rather than remembered**:
there is no field in the format capable of expressing an absolute path. It also
closes the archive-traversal ("zip slip") bug class by construction, which is the
one genuinely security-shaped surface this feature has.
- **The origin ledger.** The ledger is *this project's* record of files *it*
created, and it is the authority prune's protected set is computed from
(`src/core/tracking/CLAUDE.md` §"Scope"). Importing foreign ownership records would
assert this project's authority over another project's history. Instead the
importer writes **its own** birth records for the files it lands, at the moment it
lands them, through the one writer (`ReaSamplerSession::recordCreated`,
`src/shell/persist/session.h` — it already takes an `OriginKind`). Without that,
every imported file would be "foreign, therefore never reclaimed"
(`core/tracking/CLAUDE.md` §"Invariants", the "No silent gaps" bullet) and a user's bank folder would grow forever.
- **Live-instance usage records** (`rsusage_*`, `src/ext_keys.h`'s `kProjExtUsageKeyPrefix`). Per-instance
runtime state of a specific project's specific FX instances. Meaningless elsewhere.
- **Project state that is not bank state:** which bank was active, the Design View
mode model (`view_state`), the tail setting, the project GUID, the bank-generation
counter. A package is a bank, not a project.
- **ReaSampler 9000's `ComponentState`.** Stated above; restated here because it is
the expectation most likely to be wrong. The seam is left open, not closed: the
manifest skips unknown keys, so a future `instrumentState` section is a purely
additive change that does not bump `minReaderVersion`. Designing that seam now and
spending it later is the point.
---
## Identity and collision on import
**The import target is settled first, because it frames all four collisions.
Ε-F2, RULED by Daniel, 2026-08-02: *"always lands as a new bank, with an auto suffix
if name collision."*** Every import creates a **new bank** in the destination book.
It never merges into an existing bank, never lands into the pool, never offers a
target picker, and never overwrites. **Merge-into-existing is out of scope for Phase
Ε** — not deferred behind a flag, not a second action shipped later in this phase, not
a checkbox. A user who wants imported samples in an existing bank imports and then
uses the existing move/copy verbs, which already do exactly that and already carry
their own undo.
Four distinct collisions hide under the word "collision," and they need four
different answers.
1. **Sample id.** Ids are minted as `"cap-" + uniqueTag + "-" + fileName`
(`src/shell/capture/capture.cpp`'s `OfflineRenderBackend::capture`) and `"imp-" + …`
(`src/shell/actions/ingest.cpp`'s `importFileIntoActiveBank`) — unique within a project, **not** globally.
Re-importing a package into the project it came from would collide.
**Answer: remint every sample id on import**, under its own prefix, and remap
`Provenance::parentSampleId` (`bank_model.h`'s `Provenance` struct) through the same map — to the
reminted parent if that parent came in the same package, cleared otherwise. A
foreign id never enters the destination index. This also makes "import the same
package twice" a clean, duplicative, correct operation rather than an undefined
one.
2. **File name in the destination bank folder.** **Never overwrite.** Overwriting
would destroy an existing capture, and only prune touches existing bank bytes.
Mint a fresh unique name through the existing `deriveBankPaths` +
unique-tag machinery (`core/capture/capture_paths.h`'s `deriveBankPaths`), automatically, no
prompt, and report the count in the summary.
3. **Content hash.** `BankModel::add` collapses an equal-`contentHash` add onto the
existing entry (`bank_model.h`'s `AddResult::Collapsed`). Desirable — but
if the file was already written to disk before the collapse, it becomes an
instant orphan. **Answer: check the destination bank's `findByHash` BEFORE writing
the payload**; on a hit, skip the write entirely and report "N already present."
This is the one place the import must consult the model before touching the
filesystem, and it is a concrete acceptance criterion rather than an optimization.
4. **Bank display name.** `bank_book` enforces unique display names, trimmed and
case-insensitive ASCII (`src/core/model/CLAUDE.md` §"Invariants", the "Bank identity, movement, dedup" bullet; `createBank`'s own
contract at `bank_book.h` — *"Drums"/"drums"/" Drums " collide, including
against the pool's "Pool"*), so `createBank("Drums")` into a project that already
has "Drums" returns `false` with no mutation. **Answer: an automatic numeric
suffix, specified below.** No prompt, no overwrite, no refusal.
### The auto-suffix rule (Ε-F2, implementation-binding)
The importer picks the destination bank's display name itself. The user is told what
it picked; the user is never asked.
**The seed.** The seed is the package's recorded source bank display name, taken
**verbatim**. If that name is absent, empty, or whitespace-only after the model's own
trim, the seed is the literal `Imported bank`.
**The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness
key — strip leading/trailing ASCII whitespace, lower-case ASCII letters
(`bank_book.h`'s `BankBook::nameKey`). Take the **first** name in this sequence whose fold is not
already carried by a bank in the destination book:
seed, seed + " 2", seed + " 3", seed + " 4", …
ascending from 2, unbounded. So `"Drums"` into a project already holding `"drums"`
lands as `"Drums 2"`; a third copy lands as `"Drums 3"`.
**Four properties that make this unambiguous, each stated because omitting it lets two
implementations diverge:**
1. **The seed is never re-parsed.** A package named `"Drums 2"` colliding in the
destination lands as `"Drums 2 2"`, **not** `"Drums 3"`. This is deliberate and is
not a defect to fix: a trailing integer cannot be distinguished from a user's own
name (`"Kit 808"` would become `"Kit 2"` under a stripping rule, silently losing
user-authored text). `resample_name::nextIterationName` may increment its tail only
because ` r<N>` carries a marker; a bare integer carries none. Appending is the
safe direction — it never mutates text the user wrote.
2. **The probe fills gaps.** With `"Drums"` and `"Drums 3"` present and `"Drums 2"`
free, the import lands as `"Drums 2"`. First-free-ascending, not
highest-plus-one — the rule is a pure function of the destination's current name
set, so the same package into the same project always produces the same name.
3. **The suffix is derived from the destination, never from the package.** The package
records only its source name. Nothing about a collision is stored in the package,
and re-importing the same package into a different project can produce a different
name. The probe terminates: with `B` banks in the destination, one of the first
`B + 1` candidates is free by pigeonhole, so no cap is needed and none should be
added.
4. **The fold has exactly one home.** `import_plan` must **not** re-implement
`nameKey``bank_book.h`'s `BankBook::nameKey` says in as many words that a drifted second copy
would let the uniqueness invariant be violated. The probe therefore runs behind
`BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const`
member** to `BankBook` (recommended: `std::string uniqueDisplayName(const
std::string& seed) const`, returning the first free candidate) and calls it. That
one member is the only edit any Ε track makes to `core/model/`.
**What the suffix does NOT touch.** It renames nothing but the new bank's display
name. Sample ids are reminted by collision rule 1 regardless of whether a name
collision occurred, and the two mechanisms are independent. **`Sample` display names
are never suffixed** — two banks may legitimately hold a sample called `"Kick"`, and
`resample_name`'s own contract already states that sample display names are not unique
(`resample_name.h`'s `nextIterationName`). Bank-folder file names are handled by collision rule 2 and
are unaffected by the bank's name. `slot_map` positions ride along unchanged.
**The pool case is guaranteed, not hypothetical.** Exporting the pool is in scope (the
pool is structurally a bank), and the destination's pool always exists and always
carries the protected name `"Pool"`. So a pool export imported anywhere lands as a
**named** bank called `"Pool 2"`. That is correct under the Ε-F2 ruling — import never
lands into the pool — and it should read as intended behaviour in the summary, not as
a glitch.
**What the user sees, and their recovery.** The import summary names the bank it
created, and says so plainly when the name was adjusted:
> Imported 42 samples into a new bank: **Drums 2** (a bank named "Drums" already
> exists in this project).
The recovery path is the existing rename verb — one Ctrl-Z undoes the whole import
including the bank creation, and a rename is one gesture if the user wants a different
name. Neither needs a new affordance.
---
## Failure modes and what the user sees
Whole-package, all-or-nothing on both sides. The reasoning is the same one prune
settled on: report before acting, and never leave a half-state that looks whole.
| Failure | Side | Behaviour | What the user sees |
|---|---|---|---|
| An indexed file is missing on disk | export | Refuse by default; offer "export the N present entries" only behind an explicit confirm that lists what is missing | Message box naming the missing entries; nothing written unless confirmed |
| An indexed file is unreadable (locked/permission) | export | Same as missing | Same, distinguishing unreadable from absent |
| Destination package file exists | export | Platform save dialog's own overwrite confirm | Native dialog |
| Write fails partway | export | Temp file in the destination directory, atomic rename only on complete success | Console error; no `.rsbank` left behind. A truncated package must never exist |
| `minReaderVersion` above this build | import | Refuse whole. Nothing written, index untouched | The three-part message box above (package needs / this build reads / what to install) |
| Malformed or truncated container | import | Refuse whole. Reported **distinctly from** the version case | "This file is not a readable bank package (corrupt or truncated)." The distinction matters: the two have opposite recoveries — one is "install a newer build," the other is "get an intact copy." `origin_ledger.cpp`'s `parseStored` makes exactly this distinction for exactly this reason |
| Entry name contains a path separator, `..`, or is absolute | import | Refuse whole, before any write | "This package is not well-formed." Hostile input, not user error — no need to elaborate |
| Payload hash mismatch on any entry | import | Refuse whole, before landing anything | "This bank package is damaged (entry `<name>` failed its integrity check). Nothing was imported." |
| A write fails mid-import (disk full, permission) | import | Roll back: delete the files **this import wrote** and abandon the index mutation | "Import failed and was rolled back. Nothing was added." |
| Bank name collides in the destination | import | Auto-suffix, no prompt, no overwrite — first free of `seed`, `seed 2`, `seed 3`, … | Summary names the bank it created and says the name was adjusted |
| File name collides in the bank folder | import | Auto-rename, no prompt | Counted in the summary line only |
| Sample already present by content hash | import | Skip the write, collapse onto the existing entry | Counted in the summary line ("N already present") |
| Tracking ledger degraded at import time | import | **Refuse whole**, before the picker's bytes are read and before any write — the guard runs first | The two-case message below, mirroring prune's abort |
**On the rollback, and why it is not an invariant breach.** Prune is the single
exclusive file-deletion authority, with exactly one carve-out, stated in one place —
`src/shell/persist/prune_fs.cpp`'s file-header comment: "a shell removing a file it wrote itself
moments earlier and that no index ever referenced is self-cleanup, not authority
over user data … the discriminator is 'did this call create it, and did anything ever
reference it', not where it sits." An import rollback fits that discriminator
exactly: the files were written by this call, and the index mutation is abandoned, so
nothing ever referenced them. The spec must **cite** the carve-out rather than
restate it, or a reviewer will correctly read the rollback as a breach.
**On undo.** The index side of an import is one Ctrl-Z, through the same
`persistBankOp` undo batching every bank verb already uses
(`src/shell/bank_ops/CLAUDE.md` §"Invariants", the "One bank operation is one Ctrl-Z" bullet; `Undo_BeginBlock2` / `Undo_EndBlock2` verified
at `reaper_plugin_functions.h:7758` and `:7806`). **Undo does not un-write the
files** — they remain on disk, referenced by no index, until a prune reclaims them.
That is the same designed orphaned-until-prune window a non-empty bank delete already
produces (`src/core/model/CLAUDE.md` §"Invariants", the "Bank identity, movement, dedup" bullet). Say it out loud in the spec; do not let
a user infer that Ctrl-Z cleans the folder.
### Import under a degraded tracking ledger (Ε-F3, RULED: refuse)
**Ruled by Daniel, 2026-08-02: *"refuse mismatched import."*** An import that cannot be
cleanly reconciled against the tracking ledger is **refused outright**. There is no
confirm-and-proceed path, no "I understand the risk" checkbox, and no preference to
turn the guard off. This ruling went against the framing recommendation, and the
reasoning that carried it is recorded below rather than re-argued.
**The trigger, exactly.** The guard fires when `tracking::ledgerDegraded(status)` holds
for the project's loaded ledger status — that is, `LedgerStatus::Unreadable` or
`LedgerStatus::FutureVersion` (`src/core/tracking/origin_ledger.h`'s `LedgerStatus` and `ledgerDegraded`).
`Fresh` (absent key — a legitimate new project) and `Loaded` both proceed normally.
**Two things the guard is deliberately NOT keyed on:**
- **Not `PruneReport::blockedByTracking`.** That flag also fires on unreadable
`rsusage_*` keys, which are about live-instance protection during a *deletion*.
Import deletes nothing and computes no protected set; it *writes* birth records. An
undecodable usage key must not block an import, and reusing prune's composite flag
would silently make it do so.
- **Not the package.** Nothing in the `.rsbank` participates in this check. The package
is untouched by a refusal and remains importable later, elsewhere, or after the
project is repaired.
**When it runs.** First — **before the file picker opens**, before a byte of the
package is read, before any allocation. Making the user find and pick a file we have
already decided to refuse is the wrong order.
**What the user sees.** A console block through `ShowConsoleMsg`, mirroring prune's
abort (`src/shell/actions/prune_action.cpp`'s `doBankPruneFolder` — the `blockedByTracking` console block) in structure and in tone, because a
user who has hit prune's block should recognise this one. Every recovery line names
**this build's** ext-state namespace via `version::extStateNamespace()` — the
beta/stable trap prune already documents, where a beta user handed the stable spelling
clears the wrong key and is still blocked. Two cases, exactly one of which fires:
*Malformed ledger:*
> ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was
> imported.
> The stored file-tracking ledger is malformed. It has been left intact rather than
> overwritten, so it can be repaired or cleared:
> `reaper.SetProjExtState(0, "reasampler", "owned_files", "")`
> Clearing it makes every existing bank file un-reclaimable (they stop being
> attributable to ReaSampler); no file is lost. Reopen the project afterwards — the
> block is held for the rest of this session.
> An import can land hundreds of files in one gesture. With no readable ledger, none of
> them could be given a birth record, and every one would be permanently unreclaimable.
*Ledger from a newer build:*
> ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was
> imported.
> The stored file-tracking ledger was written by a NEWER version of ReaSampler than
> this one, so its records cannot be read safely. It has been left intact and will NOT
> be overwritten. Reopen the project with that newer version — do NOT clear this key
> from here, that would discard tracking records this build cannot see. The block is
> held for the rest of this session.
> An import can land hundreds of files in one gesture. With no readable ledger, none of
> them could be given a birth record, and every one would be permanently unreclaimable.
**The recovery path.** The status is written only by `loadFromProject`, so it is sticky
for the session (`src/shell/persist/CLAUDE.md` §"Invariants", the "A ledger this build cannot read is degraded" bullet): repair or clear the key
(malformed case only), or install the newer build (future-version case), **reopen the
project**, then import again. The package needs no re-export, and nothing about the
destination project was changed by the refusal.
**Export is NOT gated on the ledger, and that asymmetry is intentional.** Export writes
no birth records, mutates nothing, and touches no ext state. A user whose ledger is
degraded can still get their bank *out* — which is exactly the moment they are most
likely to want to. Only the landing side refuses.
**Why the ruling went this way.** The rejected option — allow the import behind an
up-front confirm — matched the accepted residual already stated at
`core/tracking/CLAUDE.md` §"Invariants" (the "No silent gaps" bullet), where a capture made during a degraded session is
recorded in memory but not persisted and degrades to foreign. The argument that carried
is **scale**: that residual contemplates *one* untracked capture, and a bulk import can
strand two hundred files in a single gesture. Same mechanism, different animal. A
confirm would also push a data-lifecycle consequence onto the user at the one moment
they are least equipped to evaluate it — mid-transfer, wanting the samples. The refusal
costs a retry after a project reload; the confirm costs a bank folder that can never be
reclaimed.
---
## Memory: the streaming seam that keeps the codec pure
A bank is not small. Float32 stereo at 48 kHz is ~23 MB per minute; a two-hundred-
sample bank is plausibly gigabytes. **The naive shape — a pure
`encodePackage(vector<uint8_t>) -> vector<uint8_t>` — holds the whole bank twice in
RAM and is unshippable.** The temptation is then to move the codec into the shell so
it can stream. That is the wrong correction, and the right one is a better seam:
- **Pure owns framing and arithmetic.** `encodeHeader(manifest) -> bytes` and
`entryLayout(manifest) -> [{ name, offset, length }]` on the write side;
`decodeHeader(prefix bytes) -> manifest + entry layout` on the read side. Offsets
and lengths are arithmetic — perfectly pure, perfectly testable, and the exact
place an off-by-one becomes a corrupt package.
- **Shell owns the stream.** It writes the header, then appends payloads one at a
time, reading each source file into a buffer, hashing it, writing it, and releasing
it. On decode it reads the prefix, gets the layout, then seeks and streams each
payload independently.
**Constraint, stated as an acceptance criterion:** the export and import paths hold
**at most one entry's payload** in memory at a time. This is what keeps the codec
pure without making the feature fail on real banks, and it is the kind of thing that
is cheap to design in and expensive to retrofit.
**One honest cost.** Export and import are synchronous, on the UI thread, like every
other action in the tool, and prune sets that precedent (a scan-then-confirm gesture
that blocks). A multi-gigabyte bank will therefore freeze REAPER for seconds. The
recommendation is to ship synchronous with a console progress/summary line and treat
async as a later move if it bites — but this is a real `[propose]`-class call the
implementation review should make deliberately rather than by default.
---
## Where it lives (pure / shell)
Two new directories, following the split the whole repo turns on.
**`src/core/package/` — pure, REAPER-free, unit-tested without a DAW.**
- `package_format` — the container framing and the version ladder in one place:
the magic, the header layout, `kPackageFormatVersion`, `kPackageMinReaderVersion`,
and `classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew |
Malformed`. The ladder lives with the framing because the ladder *is* the framing's
contract, and it gets a header-comment ladder written the way
`origin_ledger.cpp`'s version-ladder header comment writes one.
- `package_manifest` — the manifest model and its JSON codec, nesting `BankModel`'s
own blob verbatim.
- `bank_package` — header encode / prefix decode / entry layout, composing the two
above. Never holds a payload.
- `export_plan` — the pure export decision: which entries, what names, what is
missing, and therefore whether the export may proceed.
- `import_plan` — the pure import decision: the id remap table, the parent remap, the
per-entry write / skip-already-present / rename-to-avoid-collision disposition, and
the destination bank name after uniqueness folding. **This module is why the whole
feature is testable without a DAW** — every collision rule above is a pure function
over strings and hashes.
`export_plan` and `import_plan` are separate TUs deliberately, not one `package_plan`:
they share only the manifest type, and separating them is what lets the two Phase Ε
build tracks run in parallel without fighting over a file. The seam is a
responsibility seam, which is what the structural heuristic asks for.
**`src/shell/package/` — filesystem and REAPER-facing.**
- `package_io` — read a package file to bytes, write bytes through temp + atomic
rename, read a bank file's bytes, write a landed file, enumerate existing bank-folder
names, and execute the rollback delete (citing the `prune_fs` carve-out).
- The file picker, which is REAPER's own on every platform — no `#ifdef _WIN32` /
`#else swell/swell.h` split, no Win32 `GetSaveFileNameW`, no wide-char round trip.
**Verified:** `GetUserFileName(int mode, const char* caption, const char*
initial_file_or_path, const char* extension_list, char* fnOutNeedBig, int
fnOutNeedBig_sz)` — `reaper_plugin_functions.h:3790`, documented at `:3788` — serves
both verbs symmetrically: `mode=0` chooses a new file (export's destination),
`mode=1` an existing one (import's source). `extension_list` takes the
`'ReaSampler banks|*.rsbank|All files|*.*'` form. `GetUserFileNameForRead` is
explicitly "Superseded, see GetUserFileName" (`:3796`) and is not used. No fallback
is needed: `src/app/main.cpp`'s `#define REAPERAPI_IMPLEMENT` appears without
`REAPERAPI_MINIMAL`, so the resolver walks the full table (`GetUserFileName` at
`:9084`), and `REAPER_PLUGIN_ENTRYPOINT`'s `REAPERAPI_LoadAPI` check refuses to load the extension if any one function
fails to resolve — so no REAPER build that loads us can lack it.
- `export_bank` / `import_bank` — the promptless verbs, mirroring
`src/shell/bank_ops/`'s pattern exactly: take a `ReaSamplerSession&`, do the work,
return an outcome, **no prompts and no message boxes**. The bindable action and the
panel menu item are then thin skins over one verb apiece, so the logic has one home
(`src/shell/bank_ops/CLAUDE.md` §"Scope").
**The dependency-shape criterion, stated because the brief demands it.** The pure
planners take **explicit value inputs** — the decoded manifest, the destination
`BankBook`, the set of file names present in the bank folder — never a session handle,
never a service container, never a "pass me the thing that has everything." The shell
*gathers*; the core *decides*. That is the same shape `src/shell/persist/CLAUDE.md` §"Scope"
already states ("it gathers rather than decides"). If a circular dependency shows up
during the build, the fix is a service split or a thin interface at the seam — never
threading an extra parameter through a chain of constructors, and never handing a
container down. A base class that grows a dependency must not grow its subclasses'
constructors.
---
## Invariant reconciliation
- **Relative paths only.** Strengthened, not merely preserved: the package format has
no field capable of expressing a path, only a bare file name, validated at both
ends. The destination path is spelled by `capture_paths` on the importing side.
- **Capture and placement are separate acts.** Import writes files and index entries.
It places **no** timeline item, ever — the same rule capture has always carried
(root `CLAUDE.md`, "The load-bearing principle"). A user who wants the imported
audio in the arrange uses the existing insert action.
- **Prune is the single exclusive file-deletion authority.** Unchanged. The one
rollback path is the documented self-cleanup carve-out, cited not restated.
- **No lossy transforms.** The payload is opaque bytes on both sides. `wav_codec` is
invoked on it only to hash and to read metadata already recorded — never to rebuild,
trim, normalize, or collapse. The mono collapse in particular is a **capture-path**
behaviour and must not reach the import path, for the same reason ingest is already
excluded from it (root `CLAUDE.md`, exact-bounds invariant: "ingest is excluded,
because an imported file is the user's bytes, not our capture"). A package's bytes
are someone else's capture; the same exclusion applies with the same reasoning.
- **Bit-identical round-trip.** Export → import → export yields byte-identical
payloads. This is the phase's trust anchor and belongs in the acceptance criteria of
the round-trip track, tested against frozen fixture bytes rather than against a
freshly-generated pair.
- **Bank generation.** Import mutates bank content that live ReaSampler 9000
instances may play, so it must `bumpBankGeneration()`
(`src/shell/persist/session.h`'s `ReaSamplerSession::bumpBankGeneration`, whose own comment says call sites "err toward
bumping"). Export mutates nothing and must bump nothing, write no ext state, and
open no undo point.
- **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is
deliberate. Channel isolation exists so a beta cannot rewrite a stable project's
ext state (`app_version.h`'s `extStateNamespace` — the ISOLATION comment); a package is a file the user moves by hand, not
ambient project state, so there is no isolation property to preserve. A beta build
and a stable build at the same package format read each other's packages, and that
is the useful behaviour. The version ladder — not the channel — is what gates.
---
## Rulings — Daniel's, 2026-08-02
All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
Nothing here is open. This section is an index; each ruling is **specified** in the
section that owns it, and that section is the implementation-binding text.
| Fork | Ruling | Specified in |
|---|---|---|
| **Ε-F1** | **Proprietary container.** Hand-rolled `RSBK`. ZIP via the vendored MiniZip64, and a hand-written stored-only ZIP shape, are both rejected | §"The container" |
| **Ε-F2** | **Import always lands as a new bank**, with an automatic numeric suffix on a display-name collision. Merge-into-existing is out of scope for this phase | §"Identity and collision on import" — the frame, plus the auto-suffix rule |
| **Ε-F3** | **Refuse** an import while the tracking ledger is degraded. No confirm-and-proceed path | §"Import under a degraded tracking ledger" |
**Two of the three went to a different answer than the framing recommended, and the
reasons are worth keeping.** Ε-F2's recommendation was a *prompt* pre-filled with a
uniqueness-folded suggestion; the ruling removed the prompt entirely, which is the
better shape — the name is derived deterministically from the destination, the user is
told rather than asked, and the existing rename verb is the recovery. Ε-F3's
recommendation was allow-with-confirm; the ruling refused, and the counter-argument
raised alongside that recommendation is what carried it (scale — the accepted residual
contemplates one untracked capture, an import strands hundreds).
---
## Implementation decisions — Ε-W2-T1
Not [Daniel]-class forks — both were `[propose at review]` calls in `docs/PLAN.md`'s
Ε-W2-T1 track, answered at implementation review rather than by Daniel, and recorded
here per this phase's own convention for keeping such answers where the design lives
rather than only in the track's own now-stale open-questions line.
- **Affordance: both the bindable action and the panel row.** The action targets the
**active** bank and is the only spelling that can reach the **pool** (the panel's
`showTabMenu` returns early on `isPool()` — a named-bank-tab context menu has no tab
to right-click for the pool), while the exported unit's own definition above includes
the pool. The panel row is the direct gesture on a specific named bank. Neither
subsumes the other.
- **Default file name: the bank's display name**, sanitized through
`capture_paths::sanitizeStem`, seeded into `<projectDir>/<stem>.rsbank`. A
project-derived name was the rejected alternative: three banks exported from one
project must produce three distinguishable files, and a project-derived name
collides on the second export. Known wart, worth recording rather than hiding:
`sanitizeStem` collapses an all-non-ASCII display name to the literal `capture`, so
two such banks still collide — the existing rename verb is the recovery, same as the
import-side auto-suffix collisions above.
---
## Non-goals and guardrails
- **No auto-insertion of imported audio into the arrange.** Same rule as capture.
- **No overwrite of an existing bank-folder file, ever.** Auto-rename instead.
- **No partial import.** All-or-nothing, with rollback. A partially-imported bank is
the failure mode this whole design is shaped to avoid.
- **No re-encode, no trim, no normalize, no mono collapse on either side.**
- **No compression of the audio payload.** `RSBK` concatenates payload bytes; there is
no compressor in the path and none is to be added.
- **No merge-into-existing import.** Every import creates a new bank (Ε-F2). There is
no target picker, no "import into the active bank" variant, and no second action.
Move/copy already move samples between banks after the fact.
- **No instrument state in the package** — the seam is left additive, deliberately
unspent.
- **No whole-book export in this phase.** One package carries one bank, because that
is the unit users think in. A future multi-bank package is an additive manifest
change that does **not** bump `minReaderVersion`, so the option is preserved by
construction rather than by promise. Do not build it now.
- **Do not make the package a sync mechanism.** No "re-import to update," no
reconciliation against a previously-imported package, no package identity tracked
in project state. Import is a one-way copy-in. Anything else is a different product.
+26 -16
View File
@@ -53,12 +53,20 @@ snapshot/restore, forces dither and all normalize-postprocessing off, and render
32-bit float. The tail wires into that existing path — no new render trigger, no
new backend.
### Bounds are always custom — so the tail bit is always `&1`
### Bounds are always the time selection — so the tail bit is always `&4`
The backend renders with `RENDER_BOUNDSFLAG = 0` (custom time bounds) for **every**
scope and every range type: it sets `RENDER_STARTPOS` / `RENDER_ENDPOS` explicitly
from the request's exact seconds (`capture.cpp` ~L352354). It does **not** use the
time-selection / selected-items / regions bounds modes.
The backend renders with `RENDER_BOUNDSFLAG = 2` (time selection) for **every**
scope and every range type: it writes the request's exact seconds into the
project's own time selection via `GetSet_LoopTimeRange` (`capture.cpp` ~L470477;
`RENDER_STARTPOS`/`RENDER_ENDPOS` are also written, as a defensive no-op for a
mode-0-only field, but the window itself travels in the time selection). It does
**not** use the custom-time-bounds mode (`RENDER_BOUNDSFLAG = 0`) — that mode was
tried and retired: DAW observation showed REAPER resolving a custom-bounds window
on a whole-millisecond grid AT RENDER TIME, flooring the end and rendering exactly
the floored frame count, which silently broke the exact-bounds precision
invariant. The time-selection mode does not floor the window. (The one narrative
home for that finding is `render_settings.h`'s `kRenderBoundsTimeSelection`; this
doc points there rather than retelling it.)
`RENDER_TAILFLAG` is a bitmask keyed to the **bounds mode**, not the capture range
type (header line 3047):
@@ -69,18 +77,20 @@ RENDER_TAILFLAG : &1=custom time bounds, &2=entire project, &4=time selection,
&32=selected project markers/regions
```
Because we always render in custom-time-bounds mode, **the only tail bit that ever
applies is `&1`**. There is no per-range-type tail-flag decision to make — a razor
capture, a time-selection capture, and an item capture are all custom-bounds
renders under the hood, so all three take `RENDER_TAILFLAG = 1`.
Because we always render in time-selection mode, **the only tail bit that ever
applies is `&4`**. There is no per-range-type tail-flag decision to make — a razor
capture, a time-selection capture, and an item capture are all time-selection-bounds
renders under the hood, so all three take `RENDER_TAILFLAG = 4`.
> **Correction to the framing brief.** The brief asked us to pick a
> `RENDER_TAILFLAG` bit *per capture range type* (time selection vs. razor vs. item)
> and flagged `&32` as "markers/regions." The header (line 3047) says `&32` =
> *selected project regions* and `&8` = *all markers/regions* — but neither matters:
> our renders are all `RENDER_BOUNDSFLAG = 0`, so the tail bit is `&1` unconditionally.
> The existing `kTailFlagCustomBounds = 1.0` constant in `capture.cpp` (~L80) is
> already correct; the field wiring is what's missing.
> our renders are all `RENDER_BOUNDSFLAG = 2`, so the tail bit is `&4` unconditionally.
> The existing `kTailFlagTimeSelection = 4` constant in
> `src/core/capture/render_settings.h` (the bounds mode's own bit, per bounds mode —
> header line 3047) is already correct — it was right from the start; the wording
> above it (which had assumed a custom-bounds render) was what was wrong.
### Mode 1 — Automatic (default): generous tail + auto-trim to -72 dB
@@ -88,7 +98,7 @@ Set, in addition to the exact `STARTPOS`/`ENDPOS` already driven:
| Setting | Value | Meaning / header ref |
|---|---|---|
| `RENDER_TAILFLAG` | `1` | apply tail for custom time bounds (line 3047, `&1`) |
| `RENDER_TAILFLAG` | `4` | apply tail for time selection (line 3047, `&4`) |
| `RENDER_TAILMS` | `8000` | the 8 s cap, in ms (line 3048) |
| `RENDER_NORMALIZE` | `32768` | **only** the trim-ending-silence bit (line 3051, `&32768`) |
| `RENDER_TRIMEND` | `≈ 0.000251` | -72 dB threshold (line 3062; scaling below) |
@@ -156,7 +166,7 @@ The existing (currently unwired) `CaptureRequest.renderTail` / `tailMs` fields
| Setting | Value |
|---|---|
| `RENDER_TAILFLAG` | `1` |
| `RENDER_TAILFLAG` | `4` |
| `RENDER_TAILMS` | `request.tailMs` (clamped to the 8 s cap — see below) |
| `RENDER_NORMALIZE` | `262144` (`kNormalizeDisableAll`, unchanged) |
| `RENDER_TRIMEND` | not set / irrelevant (trim bit is clear) |
@@ -177,9 +187,9 @@ adds a third state, so the wiring is a small enum, not a bool:
- **None** (default for null-test / verify captures, and the current two-scope
action defaults): `RENDER_TAILFLAG = 0`, `RENDER_TAILMS = 0`, normalize =
disable-all. Exact bounds. Byte-identical to today.
- **Auto** (the new user-facing default for tail-on captures): tailFlag `1`,
- **Auto** (the new user-facing default for tail-on captures): tailFlag `4`,
tailMs `8000`, normalize `32768` (surgical trim), trimEnd `0.00025119`.
- **Manual(ms)**: tailFlag `1`, tailMs `clamp(ms, 8000)`, normalize `262144`
- **Manual(ms)**: tailFlag `4`, tailMs `clamp(ms, 8000)`, normalize `262144`
(disable-all), no trim.
Recommended shape: replace `bool renderTail` with a `TailMode { None, Auto,
+15 -15
View File
@@ -226,25 +226,25 @@ them through the reorg, not to change them:
These are the naming equivalent of the JSON-`Parser` DRY violation — concrete hazards, not taste:
1. **Four hand-rolled `Parser` classes, one name.** `class Parser` is defined **four times**
`bank_model.cpp:306`, `bank_book.cpp:663`, `owned_manifest.cpp:107`, `view_mode_model.cpp:654`.
`bank_model.cpp`, `bank_book.cpp`, `owned_manifest.cpp`, `view_mode_model.cpp`.
Q-W1 already deletes three of them by extracting `core/json`; the naming rule is that the
survivor is **`json::Parser`** (or a more specific `json::Reader`/`json::Writer` pair — see
Q-8), never a bare `Parser` in flat scope.
2. **`FooterRect` and `ButtonRect` are shared across pure UI modules — and the codebase already
*knows* it.** `struct FooterRect` and `struct ButtonRect` are defined in `prune_button.h`
(lines 32, 46) and **reused** by `footer_bar.h`, which carries an explicit in-file "NAME NOTE"
(`footer_bar.h:2734`) documenting that `ButtonRect / FooterRect / SegmentRect / ActionBarRect /
and **reused** by `footer_bar.h`, which carries an explicit in-file "NAME NOTE"
(`footer_bar.h`) documenting that `ButtonRect / FooterRect / SegmentRect / ActionBarRect /
KitBox / KitButtonBox` are "already owned in this namespace" and that new types must carry a
`FooterBar*` prefix to avoid collision. That comment is a smell made visible: the flat
`reasampler::` namespace forces every pure-UI author to hand-check for name collisions before
minting a type. This is the single strongest in-codebase argument for the Q-4 sub-namespaces —
under `reasampler::ui` these shared rect types get one clear owner and the hand-checking stops.
3. **`Sample` (`bank_model.h:69`, the bank metadata struct) vs `AudioSample` (the `peaks` float
3. **`Sample` (`bank_model.h`'s `Sample` struct, the bank metadata struct) vs `AudioSample` (the `peaks` float
alias).** Already flagged in §2.4/Q-4; verified — `Sample` is the model record, `AudioSample`
is a raw PCM float. Under `model::Sample` vs `audio::AudioSample` the collision risk is gone,
but the *names* still read oddly side by side (a `Sample` that is metadata, an `AudioSample`
that is one float). Noted; the namespace split is the required fix, a rename is optional (Q-8).
4. **`Selection` (`bank_grid.h:112`) and `CellRect` (`bank_grid.h:23`) are generic names in a
4. **`Selection` (`bank_grid.h`'s `Selection` struct) and `CellRect` (`bank_grid.h`'s `CellRect`) are generic names in a
flat namespace.** `Selection` in particular is the kind of name a newcomer cannot place without
opening the file. `ui::Selection` / `ui::CellRect` resolve it structurally; no rename needed
beyond the namespace.
@@ -255,23 +255,23 @@ Here the names are legal and non-colliding but do not read on one principle —
at" gap:
1. **The model-family suffixes disagree: `_model` vs `_book` vs `Index`.** Verified: the pure model
modules are `bank_model.{h,cpp}` (owning `class BankIndex`, `bank_model.h:132`), `bank_book.{h,cpp}`
(owning `class BankBook`, `bank_book.h:208`), `view_mode_model.{h,cpp}` (owning `class ViewModeModel`,
`view_mode_model.h:376`), `owned_manifest.{h,cpp}` (owning `class OwnedFileManifest`,
`owned_manifest.h:52`). Four modules, four different file↔class naming relationships:
modules are `bank_model.{h,cpp}` (owning `class BankIndex`, `bank_model.h`), `bank_book.{h,cpp}`
(owning `class BankBook`, `bank_book.h`'s `BankBook`), `view_mode_model.{h,cpp}` (owning `class ViewModeModel`,
`view_mode_model.h`'s `ViewModeModel`), `owned_manifest.{h,cpp}` (owning `class OwnedFileManifest`,
`owned_manifest.h`). Four modules, four different file↔class naming relationships:
`bank_model``BankIndex` (file says "model," class says "index"), `bank_book``BankBook`
(file = class), `view_mode_model``ViewModeModel` (file = class), `owned_manifest``OwnedFileManifest`
(file ≈ class, but the class adds "File"). The `bank_model`/`BankIndex` mismatch is the worst:
the file name and its primary class name share no word. This is a genuine legibility wart — the
fix is a *rename decision* (Q-8), not something the directory move alone resolves.
2. **The `bank_book` "wraps `bank_model`" relationship is invisible in the names.** `BankBook`
(`bank_book.h:208`) is a registry of `Bank` (`bank_book.h:147`), each wrapping a `BankIndex`
(`bank_model.h:132`). The names `Book``Bank``Index` do not read as a containment hierarchy;
(`bank_book.h`'s `BankBook`) is a registry of `Bank` (`bank_book.h`'s `Bank` struct), each wrapping a `BankIndex`
(`bank_model.h`). The names `Book``Bank``Index` do not read as a containment hierarchy;
a reader has to learn it. (Not necessarily worth a rename — "book of banks" is evocative — but
it is the kind of call Q-8 should make deliberately, not by accident.)
3. **`realtime_record.h` (pure) vs `capture_realtime.cpp` (shell) — the word order flips.** Verified:
the pure realtime module is `realtime_record.{h}` (owning `RecordModePlan`/`RecordPhase`/
`RecordTickInputs`, `realtime_record.h:57173`) while its shell is `capture_realtime.cpp`. So the
`RecordTickInputs`, `core/capture/capture_realtime.h`) while its shell is `capture_realtime.cpp`. So the
pure core is `realtime_record` but the shell is `capture_realtime` — the two halves of one feature
are named on inverted word order (`realtime_record` vs `capture_realtime`). Compare the *clean*
shell-pair convention elsewhere: `drag_out` (pure) ↔ `drag_out_win` (shell) — same stem, suffix
@@ -279,7 +279,7 @@ at" gap:
naming-drift instance in the tree (Q-9).
4. **`capture.{h,cpp}` is the *offline* backend shell, but the name claims all of capture.**
Verified: `capture.h` declares `ICaptureBackend`, `OfflineRenderBackend`, **and**
`RealtimeRecordBackend` (`capture.h:112,124,201`), while the realtime *implementation* lives in
`RealtimeRecordBackend` (`capture.h`'s `OfflineRenderBackend`), while the realtime *implementation* lives in
`capture_realtime.cpp` and its pure planner in `realtime_record.h`. So `capture` is really
"capture interface + offline backend," a fat header (the §2.3 Interface-Segregation concern) whose
name oversells its scope. Its Q-W3 hoist (`capture_orchestrator`/`scope_resolve`) is the moment
@@ -290,11 +290,11 @@ at" gap:
Swept for names a newcomer couldn't decode; the tree is mostly clean here (a credit to it). Two
minor notes:
- **`guid_diff` / `GuidBaseline` (`guid_diff.h:40`)** — "GUID diff" is decodable in context (it
- **`guid_diff` / `GuidBaseline` (`guid_diff.h`'s `GuidBaseline`)** — "GUID diff" is decodable in context (it
diffs the live track/item GUID set between polls) but `GuidBaseline` reads more clearly as "the
previous-poll snapshot" than the module name suggests. Low priority; leave unless its `core/view`
relocation invites it.
- **`MinMax` (`peaks.h:30`), `KitBox` (`component_geometry.h:28`)** — terse but correct and local;
- **`MinMax` (`peaks.h`'s `MinMax`), `KitBox` (`component_geometry.h`'s `KitBox`)** — terse but correct and local;
no change. Named here only to record they were swept and cleared.
### 2b.5 What the naming audit does NOT touch (hard boundary)
+260 -149
View File
@@ -31,14 +31,15 @@ own width formula, not carried over from a prior measurement. The stale geometry
**PITCH/RATE | FILTER | VELOCITY | VOICE** (sound). Row 2 is **PITCH ENV | FILTER ENV |
AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.**
- **The arithmetic closes, with room.** Minimum/default window goes **980 × 680 →
1190 × 680**, inside the settled 1280 × 720 ceiling with **90 px of headroom**. The deck
1198 × 680**, inside the settled 1280 × 720 ceiling with **82 px of headroom**. The deck
band drops **328 → 216 px**, returning **112 px to the waveform** (246 → 358 px at the
floor). **That 90 px is the governing budget for every future control addition** — one
floor). **That 82 px is the governing budget for every future control addition** — one
deck cell is 60 px, so the layout has room for exactly one more, once. §1.6.
- **The two rows align exactly, not nearly.** At the floor width the row block is 1020 px,
and at that width row 2's two gutters are equal (72 px each) *and* FILTER's right edge
lands exactly on FILTER ENV's right edge (both at x = 636). That is the aesthetic tie
between the rows and it falls out of the arithmetic — §1.3.
- **The two rows align exactly, not nearly.** At the floor width the row block is 1028 px,
and at that width BOTH rows' gutters are equal (16/16/16 and 76/76) *and* FILTER's right
edge lands exactly on FILTER ENV's right edge (both at x = 640). That is the aesthetic tie
between the rows and it falls out of the arithmetic — §1.3, which also records the three
properties the originally-specified 1020 block was claimed to deliver and did not.
- **PITCH becomes PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the existing
Varisp|Presrv toggle. Rate 50200 % exponential, Pitch ±24 st.
- **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter
@@ -49,9 +50,8 @@ own width formula, not carried over from a prior measurement. The stale geometry
when off, the lookahead when on, reported to the host's PDC. This is **routine VST3
behaviour**; the `restartComponent(kLatencyChanged)` it costs is the normal contract, and
the deactivate/reactivate the flag mandates is **accepted** — the toggle is a patch-design
gesture. The only reason the cycle is expensive at all is that **our** `setActive` re-decodes
the WAV, which is a latent improvement filed in `docs/TODO.md`, not a design constraint.
§3.1.1.
gesture. The cycle used to be expensive only because **our** `setActive` re-decoded the WAV;
Γ-W3 decoupled the two lifetimes, so it no longer does. §3.1.1.
- **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing.
- **Loop gets an explicit enable on the chrome row** (Γ-F4), and the four-mark grammar
sits under it. The core finding behind the re-approach: three identical bars draw a
@@ -94,53 +94,66 @@ contours are row 2's first two groups.
### 1.2 The measured layout
> **Updated for Ω-W1-T4's filter-mod move and toggle conversion.** The table below was
> re-measured against the shipped `deck_groups.cpp`/`knob_deck.cpp` after two changes this
> track made: the filter's mod-depth cell moved from FILTER to FILTER ENV (one cell each
> way), and the six toggles that used to be two-segment (`Off\|On` on PITCH ENV/FILTER/MASTER,
> `Staged\|Spline` on PITCH ENV/FILTER ENV/AMP ENVELOPE) are now the single-button
> `kEnable`/`kMode` styles — see `knob_deck.h`'s `DeckToggleStyle`. The per-deck corner
> radio these three envelope decks carried is gone (superseded by the click-to-focus
> overlay border, `core/instrument/CLAUDE.md`'s envelope-overlay section); MASTER alone
> keeps the corner slot, for its passive gain-reduction lamp.
`deckGroupWidth(g) = max(captionRowWidth, knobRowWidth) + 2·kDeckGroupPadX`, with
`captionRowWidth = captionWidth + Σ(kDeckToggleGap + 2·segWidth) + (radio ? 4 + 12 : 0)`
and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`. Metrics:
`kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize 40`, `kDeckCellLabelH 16`,
`kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`, `kDeckGroupPadY 4`,
`kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap 8`,
`kDeckRadioSize 12`, `kDeckGroupH 104`.
`captionRowWidth = captionWidth + Σ(kDeckToggleGap + toggleWidth) + (radio ? kDeckToggleGap +
kDeckRadioSize : 0)` and `knobRowWidth = |cellIds|·kDeckCellW + (rowToggle ? kDeckToggleGap +
rowToggleWidth : 0)`. `toggleWidth` is the WHOLE control either way — a `kSegmented` toggle
just halves it into two segments at that width; a single-button `kEnable`/`kMode` toggle
draws the whole span as one button. Metrics: `kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize
40`, `kDeckCellLabelH 16`, `kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`,
`kDeckGroupPadY 4`, `kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap
8`, `kDeckRadioSize 12`, `kDeckGroupH 104`, `kEnableBtnW 52`, `kEnvModeW 46`.
| Group | Row | Caption run | Knob run | **Width** | Δ | Control inventory |
|---|---|---|---|---|---|---|
| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (48) |
| **FILTER** | 1 | 46 + 4 + 2·32 + 4 + 2·44 = 206 | 7 × 60 = **420** | **432** | 92 | 7 cells (morph, cutoff, Q, drive, mod amt, vel, key trk); caption toggle `Off\|On` (32); **caption toggle 2 `Band\|Notch` (44) — moved from the knob row** |
| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (96, segmented) |
| **FILTER** | 1 | 46 + 4 + 52 + 4 + 2·44 = 194 | 6 × 60 = **360** | **372** | 60 vs Γ | 6 cells (morph, cutoff, Q, drive, vel, key trk)**mod amt left for FILTER ENV**; caption toggle `Filter` (52, single-button ENABLE — was `Off\|On`); caption toggle 2 `Band\|Notch` (88, segmented) |
| **VELOCITY** | 1 | 54 | 3 × 60 = **180** | **192** | 0 | 3 curve-popup cells (amp, pitch, filter) |
| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (40); **row toggle `Retrig\|Legato` (44) stays** — see note |
| **PITCH ENV** | 2 | 58 + 4 + 64 + 4 + 46 + 4 + 12 = 192 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Off\|On`; caption toggle 2 `Staged\|Spline`; corner radio |
| **FILTER ENV** | 2 | 66 + 4 + 46 + 4 + 12 = 132 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: A,H,D + 2 reserves); caption toggle 2; corner radio |
| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 + 4 + 12 = 236 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (44); caption toggle 2; corner radio |
| **MASTER** | **1+2** | 46 + 4 + 2·32 + 4 + 12 = **130** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter Off\|On` (32); corner **bubble** (12, passive); **meter column 62 px, full double height** |
| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (80, segmented); **row toggle `Retrig\|Legato` (88, segmented) stays** — see note |
| **PITCH ENV** | 2 | 58 + 4 + 52 + 4 + 46 = 164 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Envelope` (52, single-button ENABLE — was `Off\|On`); caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE — was `Staged\|Spline`); **no corner radio** |
| **FILTER ENV** | 2 | 66 + 4 + 46 = 116 | 6 × 60 = **360** | **372** | +60 vs Γ | 6 slots (Gate: A,H,D,S,R,**Mod** / Trigger: A,H,D + 2 reserves,**Mod**) — **mod amt arrives from FILTER**; caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE); **no corner radio**; no enable toggle of its own (FILTER's governs both) |
| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 = 220 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (88, segmented); caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE — was `Staged\|Spline`); **no corner radio** |
| **MASTER** | **1+2** | 46 + 4 + 52 + 4 + 12 = **118** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter` (52, single-button ENABLE — was `Off\|On`); corner **bubble** (12, passive); **meter column 62 px, full double height** |
**Row totals.**
| | Natural content | Gutters at floor | **Row width** |
|---|---|---|---|
| Row 1 | 192 + 432 + 192 + 164 = **980** | 12 + 14 + 14 = 40 | **1020** |
| Row 2 | 252 + 312 + 312 = **876** | 72 + 72 = 144 | **1020** |
| Row 1 | 192 + 372 + 192 + 164 = **920** | 36 + 36 + 36 = 108 | **1028** |
| Row 2 | 252 + 372 + 312 = **936** | 46 + 46 = 92 | **1028** |
**Window floor.**
```
deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174
kEditorMinWidth = 1174 + 2·kPad(8) = 1190
deck band width = 1028 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1182
kEditorMinWidth = 1182 + 2·kPad(8) = 1198
kEditorMinHeight = 680 (unchanged)
deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8) = 216 (was 328)
waveform band at the floor = 680 90 (chrome) 4 4 8 216 = 358 (was 246)
```
**1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.**
**1198 × 680, against a 1280 × 720 ceiling — 82 px of width headroom, 40 px of height.**
> **Who lands which half.** The floor, the three budget constants it is derived from
> (row block 1020 · MASTER 142 · ceiling 1280) and each group's row membership land in
> (row block · MASTER 142 · ceiling 1280) and each group's row membership land in
> **Γ-W1-T4**, in wave 1, so the rest of the phase is authored at the final window. The
> arrangement *inside* that budget — the justification law, the gutters, the tie-line,
> MASTER's interior — is **Γ-W3-T1**, because every one of those measures a descriptor that
> does not exist until Γ-W2-T1 and Γ-W3-T1 create it. **Row 1's natural width does not fit
> the 1020 block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, 92 from FILTER's
> `Band|Notch` caption move, = 980. Row 2's 876 already fits. `docs/PLAN.md` at Γ-W1-T4
> states the seam and the interim layout in full.
> the block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, 92 from FILTER's
> `Band|Notch` caption move, = 980. Row 2's 876 already fits. Γ-W1-T4 set the block at 1020
> and the floor at 1190; the widen recorded below moved both, and it is the ONLY number of
> W1-T4's that this phase reopened. `docs/PLAN.md` at Γ-W1-T4 states the seam in full.
Three corrections to the arithmetic in the brief, all small and all in our favour:
@@ -148,9 +161,13 @@ Three corrections to the arithmetic in the brief, all small and all in our favou
ceiling with zero slack. 142 is what the deck's own content actually needs (§1.4) and
it banks 94 px. MASTER may grow to **236** before the ceiling binds; that is the
meter's growth room, not a target.
2. **The row block is 1020, not 1016.** The extra 4 px is deliberate and is what makes the
two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest
aesthetic purchase in the phase.
2. **The row block is 1028, not 1016 and not the 1020 originally specified.** 1020 was
chosen to make the two rows align exactly; it does not — 1020 leaves row 1 a 40 px slack
that three gutters cannot divide evenly, so the justification law produces 14/13/13 and
leaves FILTER's right edge 2 px past FILTER ENV's. **1028 is the width at which the law
itself makes the tie-line exact**, with no residue in either row (§1.3). The 8 px is the
single cheapest aesthetic purchase in the phase, and it is spent from the headroom
ledger in §1.6.
3. **VOICE keeps its row toggle** — confirmed. Moving `Retrig|Legato` to the caption gives
`38 + 4 + 80 + 4 + 88 = 214`**226 px**, wider than 164, because VOICE's caption row is
the binding side and its knob row is nearly empty. Leave it.
@@ -182,11 +199,24 @@ approximate:
share a right edge.
2. **The filter tie-line.** At the floor width the two rows' filter groups end on the same
pixel:
`row 1: 192 + 12 + 432 = 636` · `row 2: 252 + 72 + 312 = 636`.
That is not a coincidence to be preserved by a special rule — it is what row-block
width **1020** buys, and at 1020 row 2's two gutters are *also* exactly equal (72/72)
and row 1's smallest gutter is *exactly* `kDeckGroupGap`. Three good properties at one
width. **This is why the floor is 1190 and not 1186.**
`row 1: 192 + 16 + 432 = 640` · `row 2: 252 + 76 + 312 = 640`.
That is not a coincidence preserved by a special rule — it is what row-block width
**1028** buys, and at 1028 both rows' gutters are *also* exactly equal (16/16/16 and
76/76), because 1028 leaves each row a slack its gutter count divides with no residue.
**This is why the floor is 1198 and not 1190.**
> **Corrected 2026-08-02 — this paragraph previously claimed THREE properties at 1020,
> and none of the three held there.** It said the tie-line landed at 636, that row 2's
> gutters were equal, and that row 1's *smallest gutter was exactly* `kDeckGroupGap` (12).
> What 1020 actually produced: row 1's slack is 40 over three gutters, so the law's
> equal-division-plus-leftmost-residue rule gives **14/13/13** — not 12/14/14 as §1.2's
> table stated, and not a smallest gutter of 12 — and FILTER's right edge lands on **638**
> against row 2's 636. Only row 2's equal gutters held. The three were never
> simultaneously satisfiable: the tie-line needs 1028, an exactly-12 smallest gutter needs
> 1016, and 1020 delivered neither. **`kDeckGroupGap` is a FLOOR — "no gutter narrower
> than 12" — never a target**, so the 16 px gutters at 1028 satisfy the real rule and the
> third property is withdrawn rather than traded away. Two properties hold at 1028, both
> exactly, and the law is what makes them hold.
3. **Shared horizontal baselines.** Every group is `kDeckGroupH` with identical interior
offsets, so across both rows the caption text, the knob centrelines and the label bands
sit on the same four lines. The reflow must not break this — it is free today and
@@ -249,32 +279,37 @@ Horizontally the group is `6 + 60 + 8 + 62 + 6 = 142`.
| Deck rows at the floor width | 3 (by greedy wrap) | **2 (by construction)** |
| Deck band height | 328 | **216** |
| Waveform band at the floor | 246 | **358** |
| Minimum / default window | 980 × 680 | **1190 × 680** |
| Ceiling headroom | — | **90 px wide, 40 px tall** |
| Minimum / default window | 980 × 680 | **1198 × 680** |
| Ceiling headroom | — | **82 px wide, 40 px tall** |
**Costs, named.** The floor width grows by 210 px — an existing saved instance's window
**Costs, named.** The floor width grows by 218 px — an existing saved instance's window
grows on open (the same one-time effect Θ-W6-T1 already shipped at 840 → 980, so the
behaviour is precedented, not new). The deck's wrap mechanism stops being the thing that
decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom
budget — §1.6.
### 1.6 The 90 px headroom is the budget, and it governs every future control
### 1.6 The 82 px headroom is the budget, and it governs every future control
**Read this before proposing any new knob.** The floor is **1190** against Daniel's hard
**1280** ceiling. That is **90 px of width headroom for the life of this layout**, and it is
**Read this before proposing any new knob.** The floor is **1198** against Daniel's hard
**1280** ceiling. That is **82 px of width headroom for the life of this layout**, and it is
the single constraint every later addition spends from:
| Purchase | Cost | Headroom after |
|---|---|---|
| One more 60 px deck cell on row 1 | 60 | 30 |
| One more caption toggle on a group whose caption row is the binding side | 048 | 4290 |
| Widening MASTER to a two-cell left column | 60 | 30 |
| One more 60 px deck cell on row 1 | 60 | 22 |
| One more caption toggle on a group whose caption row is the binding side | 048 | 3482 |
| Widening MASTER to a two-cell left column | 60 | 22 |
| A second cell *and* a wider MASTER | 120 | **over ceiling** |
**The ledger was 90 until the row block widened 1020 → 1028** (§1.2 correction 2, §1.3). Its
*purchasing power* is unchanged: one more 60 px deck cell remains affordable (82 60 = 22),
which is the only purchase this ledger has ever promised, and the second one was already over
the ceiling at 90. The 8 px came out of the spare change, not out of the budget's one slot.
**This is why MASTER's reserved lower-left slot is ONE cell and not two** (Γ-F5, ruled by
Daniel 2026-08-01). A two-cell reserve would spend 60 of the 90 up front, on a control
Daniel 2026-08-01). A two-cell reserve would spend 60 of the 82 up front, on a control
nobody has named yet, and would effectively freeze row 1 forever: any later row-1 addition
would then need the remaining 30 px and would not have it. One cell keeps the spare. If the
would then need the remaining 22 px and would not have it. One cell keeps the spare. If the
future master-bus control turns out to be two knobs, widening MASTER **then** costs the same
60 px it would cost now, and by then the trade is being made against a real control instead
of a guess. **Reserving capacity you have not designed a use for is not free here — it is
@@ -287,7 +322,7 @@ Two corollaries for a reader who wants to add something:
FILTER's `Band|Notch` move exploits). A cell always costs its 60 px.
- **The chrome row is a separate budget.** The toolbar row's right-anchored control run is
paid for out of the *title* slot, not out of the window floor — which is why the loop
enable (§6.5) costs zero of the 90. That is a genuinely different purse and must not be
enable (§6.5) costs zero of the 82. That is a genuinely different purse and must not be
confused with this one.
---
@@ -344,16 +379,16 @@ shift unless that contingency is taken.
**Settled: Rate is latched at note-on for this phase (not live on sustaining voices).**
Two consequences the implementation must get right:
**It is a latch, not a reload.** `isLiveDeckParam` is currently a binary predicate whose
`false` branch routes an edit to a **full reload** (bridge read, WAV re-decode, fresh
**It is a latch, not a reload.** `deckParamCommit` was a binary predicate whose
`false` branch routed an edit to a **full reload** (bridge read, WAV re-decode, fresh
engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is
therefore a **third commit class**: *published into the live block like any live parameter,
but read only by `snapLive` at note-on and never by `applyLive` on a sounding voice.* The
mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is
exactly this split — but the *classification* does not.
exactly this split — but the *classification* did not, until this phase widened it.
> **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are
> live is ONE decision, recorded in ONE place"* — `isLiveDeckParam` / `liveCommitFor` in
> live is ONE decision, recorded in ONE place"* — `deckParamCommit` / `liveCommitFor` in
> `ui/deck_groups`. Phase Γ widens that one decision from two states to three
> (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere.
> This is also precisely the seam the automation work needs — see
@@ -374,12 +409,23 @@ Preserve it is an addend to a shift amount the pitch envelope already modulates.
### 2.4 Rate scaling — what "scales with rate" means, concretely
- **Loop points scale with rate.** The loop is a pair of *source-frame* facts. Under
Varispeed the read increment changes and the loop is traversed proportionally faster —
scaling is automatic and the stored frames are untouched. Under Preserve the read
advances at `rate ×` the source rate, so the loop's wall-clock period scales by `1/rate`
while its source-frame span is unchanged. **In neither mode are the stored loop frames
rewritten**; the marks on the waveform do not move when Rate moves.
- **Loop points scale with rate under Varispeed; under Preserve, the loop's *traversal*
scales and its audible period does not.** The loop is a pair of *source-frame* facts.
Under Varispeed the read increment changes and the loop is traversed proportionally
faster — scaling is automatic, the stored frames are untouched, and the audible period
scales by `1/rate` along with everything else the voice plays. **Under Preserve this is
the opposite of what the Varispeed case suggests, and the obvious extension of it is
wrong** — which is why an engineer measured this before writing code against it rather
than inferring it from the Varispeed case above. What scales with rate under Preserve is
the loop's *traversal* — how fast the source is consumed (the feed-side witness is
`testPreserveStretchLoopsTheSourceSpan`) — not its audible period: holding the source's
period constant while its duration changes is what Preserve *is*. **Measured** (Debug
build, one machine): with a ring long enough to hold the whole loop, the rendered
sawtooth period is ~3999.9 output frames at rate 0.5, 1.0, and 2.0 alike; at shorter
rings, where splice cadence intrudes instead of the design property being isolated, the
same fixture measured 3064 and 4130 frames at rate 0.5 — never the 8000 a scaling period
would give either. **In neither mode are the stored loop frames rewritten**; the marks on
the waveform do not move when Rate moves.
- **Contours scale with rate.** A drawn contour is a pure function of *normalized* sample
position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour
length-independent"), so it follows the read head by construction. **The staged
@@ -525,31 +571,28 @@ plugins — lookahead limiters, linear-phase EQs and oversampling processors all
REAPER handles it as a matter of course. The deactivate/reactivate is the *normal* cost of
the flag, and for a typical plugin it is cheap: `setActive` allocates and frees buffers.
**What makes it expensive here is entirely our own design, in one line.**
`ReaSamplerProcessor::setActive` is deliberately destructive in both directions
(`reasampler_processor.cpp:85-109`):
**What made it expensive here was entirely our own design, in one line** — and Γ-W3 removed
that line. `ReaSamplerProcessor::setActive` was deliberately destructive in both directions:
- `setActive(true)` calls `reloadInstrument()` (`:89-97`) — **a bridge read and a full WAV
re-decode**, plus a fresh engine. This is the expensive half, and no part of it is required
by the SDK: it is there because activation was the convenient trigger for a reload, not
because activation implies one.
- `setActive(false)` frees `live_`, `draining_` **and** the graveyard (`:98-107`), so every
sounding voice dies. The comment there explains why that is correct and must not be
softened casually: a surviving `live_` would be displaced into the drain slot on reactivate
and *"resurrect stale sustained voices as ghosts."*
- `setActive(true)` called `reloadInstrument()`**a bridge read and a full WAV re-decode**,
plus a fresh engine. That was the expensive half, and no part of it was required by the SDK:
it was there because activation was the convenient trigger for a reload, not because
activation implies one.
- `setActive(false)` frees `live_`, `draining_` **and** the graveyard, so every sounding voice
dies. That half is correct and must not be softened casually: a surviving `live_` would be
displaced into the drain slot on reactivate and *"resurrect stale sustained voices as
ghosts."*
**So the cost is ours, and it is ours to reduce.** The reduction is **decoupling the reload
from activation** — keeping the decoded `SampleData` alive across a deactivate while still
destroying voice state, which is exactly the shape `rebuildVoiceEngine`'s drain-slot swap
already implements for voice-count edits. **That is a latent improvement with a clear trigger
condition, filed in `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") —
not a reason to abandon dynamic latency, and not scheduled in this phase.**
**The cost was ours, and it has been reduced (Γ-W3 — see §7.11).** The deactivate now parks the
decoded `SampleData` and the reactivate rebuilds only the voice state around it, through the
same drain-slot swap `rebuildVoiceEngine` uses for voice-count edits. An activation cycle costs
no disk read and no decode; an instance with nothing decoded still takes the full reload, which
is where the pre-v10 legacy lift lives.
**The honest cost of the toggle today, stated plainly:** every sounding note stops and the
sample is re-decoded from disk. **Daniel has accepted it** (Γ-F6): *"Toggling the limiter
killing the voices isn't a deal breaker though, the limiter will either be on or off on its
instance, toggling during playback is not a use case."* There is no fallback design and no
measurement gate.
**The honest cost of the toggle, stated plainly:** every sounding note stops. **Daniel has
accepted it** (Γ-F6): *"Toggling the limiter killing the voices isn't a deal breaker though,
the limiter will either be on or off on its instance, toggling during playback is not a use
case."* There is no fallback design and no measurement gate.
#### The standing scar, and why this is nonetheless not the forbidden change
@@ -625,10 +668,9 @@ What is in scope alongside it — and what each is actually for:
in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass`
parameter either.
- **Observe what REAPER does, and record it — as evidence, not as a gate.** Whether notes
cut, whether the re-decode is perceptible, whether transport hiccups, is DAW-observable
only. Record it in Γ-W1-T2's review because it is the trigger-condition evidence for the
`docs/TODO.md` decoupling entry. **No outcome changes the design**; Γ-F6 is closed either
way.
cut and whether transport hiccups is DAW-observable only. The re-decode half of that
question is gone (§7.11), so what remains to observe is the voice cut alone. **No outcome
changes the design**; Γ-F6 is closed either way.
### 3.2 The meter
@@ -683,8 +725,19 @@ reduction is applied.**
Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in").
Derived against that rule — **no new Daniel call**: **rate → reset**, **pitch offset →
reset**, **limiter enabled → reset** (master gain is already on the reset list, so the bake
includes the master stage, so the limiter's effect is in the audio).
reset**, **limiter enabled → reset**.
**The limiter clause's original reasoning was false, and the code was changed to make its
conclusion true.** It read "master gain is already on the reset list, so the bake includes
the master stage, so the limiter's effect is in the audio" — but the bake printed a flat
gain multiply and nothing else; the limiter ran in the processor's block, off the bake path,
so a capture baked with it engaged came back unlimited and resetting the enable was
resetting a control whose effect was NOT in the file. Daniel ruled the goal rather than the
premise: `renderBake` now prints the whole master stage, gain then limiter, so the
classification stands on the rule it always claimed to. The lookahead is compensated inside
the render, and a bypassed bake is the pre-limiter render frame for frame —
`src/core/instrument/bake/CLAUDE.md` owns both, plus the double-limiting boundary a baked
capture inherits.
**This is now a CORRECTION, not a sequencing note.** The original plan required Phase Γ to
land before Ξ-W2 so the bake's reset list would be complete on the day it shipped. **That
@@ -776,6 +829,19 @@ model"* — a snap available on one and not the others would be a divergence.
> "whole number" above the frame. Overloading Shift there would collide with a shipped
> gesture. Recorded as a deliberate exclusion.
> **AMENDED (Ω-W2-T5, Daniel 2026-08-03) — Ctrl now has a marker meaning too, and it is not
> this table's Ctrl row.** Dragging a waveform marker snaps it to the nearest zero crossing
> within a small pixel radius (`core/instrument/ui/waveform_view`'s `snapToZeroCrossing`);
> holding Ctrl defeats that snap outright rather than scaling the drag by 0.05. This does not
> reopen the exclusion above or the Shift question it answers: the exclusion is about **Shift**
> colliding with the shipped zero-crossing gesture on a domain with no meaningful whole number
> to snap to, and that reasoning is untouched — Ctrl's marker meaning is a different modifier
> defeating a different gesture (an existing snap, not this section's taper), not a second
> "whole number" law for frames. The two Ctrl meanings (defeat-the-snap here, fine-drag in
> §4.1's own table) do not collide in practice because they land on disjoint control families —
> a marker is never also a unit-valued knob — but the marker's own Ctrl is a genuinely separate
> semantic and is recorded as such, not folded into the row above.
### 4.2 The snap unit, by category
| Unit category | Controls | Shift snaps to |
@@ -1042,13 +1108,20 @@ being a bare orphan rectangle and becomes the same kind of object as every other
| Mark | Ink | Cap | Line | Label |
|---|---|---|---|---|
| **Start** | `accent/primary` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line |
| **Start** | `overlay/trace` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line |
| **Loop start** | `accent/secondary` | **L-cap opening right** | solid | `LOOP`, right of the line |
| **Loop end** | `accent/secondary` | **L-cap opening left** | solid | `END`, left of the line |
| **Crossfade** | `accent/secondary`, reduced alpha | **ramp cap** — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape | **dashed** — a soft boundary, not a hard one | `XFADE`, left of the line |
Start is the only `accent/primary` mark in the band, because it is the only one that is
always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as `[ … ]`
Start draws in `overlay/trace`, not `accent/primary`: `accent/primary` **is** the waveform
fill, so a primary START would measure 1:1 against the material it marks. `overlay/trace`
measures 3.071:1 against the fill and 3.065:1 against `bg/base`, clearing the 3:1 non-text
floor on both — provably optimal, since `core/ui/CLAUDE.md`'s two-neighbour rule derives
`sqrt(9.41) ≈ 3.07` as the ceiling any single value can hold against both neighbours at
once. Start is still the only mark always in effect (both Gate and Trigger), but that is no
longer what its ink says, now that `overlay/trace` is shared with the envelope trace: the
distinction is carried by shape instead — a straight full-height column under a solid
triangle cap, never a curve. The loop pair's opposed L-caps read as `[ … ]`
without needing to be explained. All four caps use primitives already in the kit
(axis-aligned fills, AA-restroked triangles per `visual-design-language.md` §8).
@@ -1088,31 +1161,43 @@ cosmetic gain.
> already-accepted failure worse.** The edge wedge leaves the loop fill's peak alpha at
> 0.20 exactly as today, so the pair is untouched.
- **The ingredient draws as a ghost.** `[loopStart crossfade, loopStart)` — the material
actually being mixed in — draws the **mirror** wedge (growing right-to-left, peaking at
`loopStart`) at half alpha, outside the loop fill. It carries no handle. **At rest it is a
hairline dashed outline; it fills in on hover or drag of the crossfade handle** — a hover
state in the sense §3.3 of the visual language means, revealing the relationship only when
the user is asking about it.
actually being mixed in — draws the **same ramp** the audible wedge draws (growing
left-to-right, peaking at `loopStart`) at half alpha, outside the loop fill — not a
mirror of it: the incoming tap's weight at ingredient frame `loopStart crossfade + k` is
the same `crossfadeWeight` as audible frame `loopEnd crossfade + k`, so both spans carry
the identical ramp, which is exactly why one `crossfadeWedgeHeight` function draws both.
It carries no handle. **At rest it is a hairline dashed outline; it fills in on hover or
drag of the crossfade handle** — a hover state in the sense §3.3 of the visual language
means, revealing the relationship only when the user is asking about it.
- **This makes the clamp self-explanatory.** The hard clamp is
`crossfade ≤ min(start, loopLength)` (`loop_span.h:19`, and its "no material ahead of the
loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn, **the fade stops
growing exactly when the ghost's left edge reaches the START mark or the LOOP mark** — the
user sees the reason instead of hitting an invisible wall. That is the single best payoff
in this design and it costs nothing extra.
`crossfade ≤ min(loopStart, loopLength)` (`loop_span.h:19` — `maxCrossfade(loopStart,
loopEnd loopStart)`; `start` there names `loopStart`, not the START mark — and its "no
material ahead of the loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn,
**each half of the clamp is now visible, on a different mark:** the ghost's left edge
reaches frame 0 — the overlay's own left edge, not a mark — exactly at the `loopStart`
bound, and the audible wedge's left edge reaches the LOOP mark exactly at the `loopLength`
bound. The user sees why the fade stopped growing instead of hitting an invisible wall.
That is the single best payoff in this design and it costs nothing extra.
**(d) The off-state and the Trigger state get words, not just alpha.**
- **Loop off.** The pair draws in the kit's **Disabled** state with a centred dim caption in
the span — `DRAG TO SET LOOP` when no span has ever been set (the pair is parked at the
last quarter, `defaultLoopBounds`), `LOOP OFF` when a span is retained. The full off-state
machine, and what the explicit enable does to it, is **§6.4**.
- **Trigger mode.** Loop is Gate-only (`resolveLoop` refuses in Trigger) but the markers
still draw at full strength today, which is marks that do nothing. **In Trigger the loop
pair and the crossfade mark draw Disabled and are not grabbable**, with a dim
`LOOP — GATE ONLY` caption in the span. Disabled rather than hidden, because that is the
established grammar — the editor's Gate segment already refuses and paints Disabled off
the `splineActive` predicate — and because hiding a set loop on a mode flip destroys
information the user put there. The START mark stays fully live in both modes.
> **AMENDED by Ω-F2 (Daniel, 2026-08-03).** The Trigger bullet below is REVERSED and the
> `LOOP — GATE ONLY` caption is retired; the Gate off-state bullet survives with its styling
> corrected. The amended text is what ships — see `docs/PLAN.md` §Phase Ω, Ω-W1-T5.
- **Loop off (Gate).** The pair stays **drawn and grabbable**, styled **grey disabled** — not
the dim teal that shipped, which read as decoration rather than as an off state
(*"that was not intuitive to me"*). The centred dim caption in the span survives and still
says which off state you are in: `DRAG TO SET LOOP` when no span has ever been set (the pair
is parked at the last quarter, `defaultLoopBounds`), `LOOP OFF` when a span is retained. The
full off-state machine, and what the explicit enable does to it, is **§6.4**.
- **Trigger mode.** Loop is Gate-only (`resolveLoop` refuses in Trigger). **In Trigger the loop
pair and the crossfade mark are HIDDEN OUTRIGHT** — not Disabled, not dim: absent, along with
their labels and the caption. *"Trigger mode doesn't work with loop anyway."* The stored span
is untouched and returns with the mode, so nothing the user put there is destroyed — only its
drawing is suppressed where it can mean nothing. **The superseded rule, recorded so it is not
reinstated: the marks once drew Disabled-rather-than-hidden in Trigger, with a dim
`LOOP — GATE ONLY` caption in the span.** The START mark stays fully live in both modes.
### 6.4 The explicit loop enable (Γ-F4, ruled by Daniel 2026-08-01)
@@ -1143,8 +1228,8 @@ Three reasons for that exact slot:
2. **Browse stays rightmost.** It is navigation, not a mode — moving it would break the
established right-edge reading.
3. **It costs zero window width.** The run is right-anchored and the title slot absorbs it,
so `kEditorMinWidth` does not move and **none of §1.6's 90 px headroom is spent.**
*Constraint:* the title slot must still hold its text at the 1190 floor. If it will not,
so `kEditorMinWidth` does not move and **none of §1.6's 82 px headroom is spent.**
*Constraint:* the title slot must still hold its text at the 1198 floor. If it will not,
the enable's segments narrow — the floor does not move. That is a hard rule, because the
floor is a phase-wide acceptance criterion.
@@ -1201,34 +1286,44 @@ The parked pair at the last quarter was carrying two messages in one alpha value
no loop* **and** *drag here to make one*. The enable takes the first message; the pair keeps
the second.
- **Off, no span ever set** — pair parked at `defaultLoopBounds`, drawn Disabled, caption
`DRAG TO SET LOOP`. Dragging either mark **turns the enable on.** The shipped
drag-to-create gesture survives intact, and it now teaches the enable by demonstration:
the user drags and watches the chrome toggle light up.
- **Off, span retained** — pair drawn Disabled *at its own positions*, caption `LOOP OFF`.
There is nothing to "set," so the drag-me copy would be wrong. Dragging still turns the
enable on, by the same rule.
- **Off, no span ever set** — pair parked at `defaultLoopBounds`, drawn **grey disabled**
(Ω-F2; the shipped dim teal was not readable as an off state), caption `DRAG TO SET LOOP`.
Dragging either mark **turns the enable on.** The shipped drag-to-create gesture survives
intact, and it now teaches the enable by demonstration: the user drags and watches the chrome
toggle light up.
- **Off, span retained** — pair drawn **grey disabled** *at its own positions*, caption
`LOOP OFF`. There is nothing to "set," so the drag-me copy would be wrong. Dragging still
turns the enable on, by the same rule.
- **On** — full four-mark grammar of §6.3, unchanged.
> **A grab implies intent to loop.** That is the one rule behind both off-states, and it is
> what keeps the enable from being a gate the user has to remember to open.
> **A grab implies intent to loop. This rule SURVIVES Ω-F2 and is the reason the Gate off-state
> marks stay grabbable rather than going grey-and-inert.** It is what keeps the enable from
> being a gate the user has to remember to open. It has nothing to say about Trigger, where
> there is no mark to grab.
#### The Trigger case — the enable disables itself, it does not clear itself
Per the Disabled-not-hidden principle already established for the marks: **in Trigger the
chrome-row enable draws Disabled and inert, with its state preserved and restored on the
return to Gate.** It does not clear `hasLoop`, and it does not hide. The enable's Disabled
state and the span's `LOOP — GATE ONLY` caption are the same message delivered at two
scales — the chrome row says *this control is unavailable here*, the span says *why*.
> **AMENDED by Ω-F2 (Daniel, 2026-08-03).** The MARKS are now hidden in Trigger (§6.3(d) as
> amended); the chrome-row ENABLE is not. The two halves of the old "same message at two
> scales" argument no longer travel together, and the paragraph below says which one survives.
**In Trigger the chrome-row enable draws Disabled and inert, with its state preserved and
restored on the return to Gate.** It does not clear `hasLoop`, and it does not hide — a control
that vanishes from the chrome row costs the user the map of what the instrument has. **The
span's `LOOP — GATE ONLY` caption is retired along with the marks it was centred in**, so the
chrome row now carries the message alone: *this control is unavailable here*. The *why* is the
mode segment sitting beside it.
This transitively covers the drawn-EG case: `enforceGateUnavailableWhileDrawn`
(`play_params.h:198-205`) forces Trigger whenever any envelope is drawn, so a spline EG
(`play_params.h`) forces Trigger whenever any envelope is drawn, so a spline EG
disables the loop enable through the same predicate rather than through a second rule.
**Disabled-but-grabbable (the off marks) vs. Disabled-and-inert (Trigger) is a deliberate
distinction, not an inconsistency**, and the discriminator is who said no: the user's own
off is reversible by the very gesture being offered, while Trigger's refusal comes from the
engine and no marker drag can talk it out of it.
**Three states, not two, and collapsing any pair of them is a defect:** *grey-disabled but
grabbable* (the Gate off marks — the user's own off, reversible by the very gesture on offer),
*Disabled and inert* (the chrome enable in Trigger — the engine's refusal, which no drag can
talk it out of), and *absent* (the marks in Trigger — a position that can mean nothing in this
mode). The discriminator between the first two is who said no; the discriminator for the third
is whether the thing being drawn could be acted on at all.
### 6.5 Trade-offs and consequences, named
@@ -1309,7 +1404,7 @@ the floor without touching cell metrics, because the **group inventory and its r
assignment** now also drive it. Restate as: *the deck's cell metrics AND its group/row
composition both drive `kEditorMinWidth`; none of the three may move alone.*
**7.5 — `isLiveDeckParam` becomes three-valued.** See §2.3. The exhaustive switch must
**7.5 — `deckParamCommit` becomes three-valued.** See §2.3. The exhaustive switch must
classify the two new `DeckParam`s or fail to compile — which is exactly what it is designed
to do, and which is why the two new parameters are cheap to add *now*.
@@ -1353,13 +1448,27 @@ squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive
directions. **Those four are hygiene against the `kIoChanged` scar (§3.1.1), not a hedge
against the flag itself** — Γ-F6 is ruled and the restart ships.
**7.11 — `setActive` conflates two lifetimes, and dynamic latency is the first feature that
makes a user notice.** Activation currently means both "the audio thread may run" and "the
decoded `SampleData` is (re)built" (`reasampler_processor.cpp:89-97`). Phase Γ does **not**
separate them — Γ-F6 accepts the cost — but the conflation is now a named, filed improvement
(`docs/TODO.md`, "Decouple the instrument reload from VST3 activation") rather than an
unremarked property. **Do not restructure `setActive` inside this phase**; its destructive
shape is deliberate and its reasoning is documented at the call site.
**7.11 — `setActive` conflated two lifetimes; it no longer does (LANDED, Γ-W3).** Activation
used to mean both "the audio thread may run" and "the decoded `SampleData` is (re)built", so
every host-driven cycle paid a bridge read and a full WAV decode. The two are now separate:
`setActive(false)` parks the decoded sample and destroys the voice state, `setActive(true)`
rebuilds the voices around the parked sample through the drain-slot swap `rebuildVoiceEngine`
already used. **This section's earlier instruction — "do not restructure `setActive` inside
this phase" — was superseded by Daniel's ruling that this track does it**; the deactivate's
destruction of voice state is still deliberate (a surviving `live_` would resurrect stale
sustained voices as ghosts) and only the PCM survives. Nothing parked routes the activation
back through the full reload, which is what keeps the pre-v10 legacy lift reachable. Γ-F6 is
untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost —
it is simply a much cheaper one.
**What "cheaper" does NOT mean: skipping the bank fold.** `reloadInstrument` also runs the
recapture sync and the `rsusage_` prune-protection publish, and with no editor open the
activation is the only place either happens (`pollBankSync` runs off the editor's sync tick and
nothing else). Both are a `GetProjExtState` plus a parse — neither disk nor decode — so both run
on the resume path too, and a fold that moves the loaded capture's decode source hands back to
the full reload rather than resuming PCM the bank has superseded. A resume that refreshed the
refs without re-decoding would be the worst of the three: the table would name a recapture's new
file while the voices played the old one.
---
@@ -1378,8 +1487,8 @@ ceiling.
| **Γ-F2** | Limiter lookahead, or zero-latency? | **Lookahead with DYNAMIC reported latency** — zero when off, the lookahead when on, reported to the host's PDC. *Overrides this doc's zero-lookahead recommendation.* | **§3.1.1** (new), §7.10 |
| **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **REVERSED, same day. Ruled first "not in this phase — stays 2.0 s"; then Daniel: _"extend the stage lengths to 10s."_ The ceiling moves 2.0 → 10.0 in Γ-W1-T1.** The reversal's cause is Ruling 1: parameters now ship in-phase, so the ceiling is a one-way door that has to be walked through *before* them. | **§4.3.1** (new), §4.3; `docs/TODO.md` entry discharged |
| **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 |
| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 |
| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` |
| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 82 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 |
| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11; `docs/TODO.md` decoupling entry discharged in Γ-W3 |
| **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow***"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) |
Three of these corrected this doc rather than confirming it, and all three corrections are
@@ -1425,7 +1534,8 @@ reintroduced:
than just counting:
1. **§3.1.1 was rewritten, not annotated.** Its prior framing — dynamic latency as exotic and
expensive — was wrong. Dynamic PDC is routine; the expense is our reload-on-activate.
expensive — was wrong. Dynamic PDC is routine; the expense was our reload-on-activate, and
Γ-W3 removed it (§7.11).
2. **The measurement gate was dropped.** Γ-W1-T2's first deliverable is the limiter, not a
spike. What remains is an *observation* recorded in review as evidence for the deferred
improvement — it gates nothing.
@@ -1435,7 +1545,7 @@ than just counting:
4. **The verification requirements survive unchanged**, because they were always about the
`kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session
`restartComponent`), not about this flag.
5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`.
5. **The reduction was filed with a trigger condition and has since LANDED** (Γ-W3 — §7.11).
### 8.3 Γ-F7 — RULED: signal flow. The parameter order
@@ -1496,9 +1606,10 @@ Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), **fo
T1 pitch-rate-deck ................. item A (params + engine + deck descriptor)
T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry
+ the chrome-row loop enable)
Γ-W3 The reflow, and the bake correction [2 tracks]
Γ-W3 The reflow, and the bake correction [3 tracks]
T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half
T2 bake-reset-amendment ............ the Phase Ξ correction Γ owns (§3.4)
T3 bake-prints-limiter ............. prints the limiter through the bake's master stage (§3.4)
Γ-W4 VST3 parameters [1 track]
T1 vst3-parameter-set .............. Ruling 1 (parameter-automation.md §§6-10)
```
@@ -1510,7 +1621,7 @@ ceiling into W1 (§4.3.1) and turned the Ξ ordering constraint into an owned co
1. **Item B splits: canvas early, arrangement late.** The window floor, the width budget it
derives from, and each group's row membership land in W1-T4 so every other UI track is
drawn, tested and judged at the final 1190 × 680 window. The two-row layout itself stays in
drawn, tested and judged at the final 1198 × 680 window. The two-row layout itself stays in
W3-T1, because it can only be measured once the final PITCH/RATE and MASTER descriptors
exist. The exact seam — what W1-T4 can assert, what it cannot, and what the editor looks
like in between — is in `docs/PLAN.md` at Γ-W1-T4.
@@ -1522,7 +1633,7 @@ The wave boundaries are collision boundaries, not preferences: `deck_values.cpp`
by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; `deck_groups.cpp` by
W1-T4 (the row predicate) then W2-T1 (the descriptor) then W3-T1 (the row consumption);
`voice.cpp` by W1-T5 then W2-T1; and **one params-payload version bump per wave, owned by one
track** (W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) — the
track** (W1-T2 takes v15 for the limiter flag, W2-T1 takes v16 for rate + pitch offset) — the
two new W1 tracks take **no rung at all**, so the ladder is unchanged by the resequencing.
Two shared files are named rather than discovered at merge:
`core/instrument/engine/CMakeLists.txt` inside W1 (T2 | T5) and `editor_session.cpp` inside
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1117,7 +1117,7 @@ Addendum is the *why*; those are the *what/how*.
**Framing.** Folds one more control into the S-VIEW redesign: a **visual velocity → amp
transfer-curve editor**. Today the engine maps velocity to gain *linearly* (`velocityGain_ =
velocity / 127.0`, `sampler_core.cpp:261`), applied once at note-on in `Voice::start()`. Daniel
velocity / 127.0`, `Voice::start()`), applied once at note-on in `Voice::start()`. Daniel
wants that mapping to become an **editable transfer curve** — a bezier from a default flat line to
an arbitrary multi-point curve — so velocity dynamics are fully shapeable per sound.
@@ -1181,7 +1181,7 @@ a LICE shell that draws handles and routes the mouse).
evaluation is called at note-on, not per frame (see call 4).
4. **Voice-engine application point → `Voice::start()`, replacing the linear `velocity/127`.**
Confirmed from source: `sampler_core.cpp:261` computes `velocityGain_ = velocity / 127.0` **once
Confirmed from source: `Voice::start()` computes `velocityGain_ = velocity / 127.0` **once
at note-on** inside `Voice::start()`; the per-frame render path (`advanceFrame`, line 408:
`gain = amp * velocityGain_`) then just multiplies the cached scalar. So the transfer curve
slots in at exactly one line: `velocityGain_ = curve.eval(velocity)` at note-on — **off the
+52 -22
View File
@@ -124,7 +124,7 @@ than balancing it — a fork with a dominated option in it is not a fork.
### 3.4 Which controls can be parameters at all — three classes
The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` /
The good news: **this analysis is already done once, in one place.** `deckParamCommit` /
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild,"
which is the same question automation asks. Phase Γ widens it from two states to three
(§3.5). The parameter work should widen the *same* decision point again rather than start a
@@ -202,8 +202,8 @@ The consequence for this doc is concrete and it is a **subtraction from the para
> latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
> has to deactivate and reactivate the plug-in"*
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees
> every sounding voice and a reactivate re-decodes the WAV. **An automation lane toggling
> that parameter would deactivate the plugin on every flip.**
> every sounding voice. **An automation lane toggling that parameter would deactivate the
> plugin on every flip.**
Two corollaries the parameter work must carry rather than rediscover:
@@ -211,7 +211,7 @@ Two corollaries the parameter work must carry rather than rediscover:
binding it to `kIsBypass` would hand the host a control that restarts the component.
- **Latency reporting must be derived from persisted state, not from a transient.** The SDK
states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and
this plugin's `setActive(false)` frees essentially everything. Whatever holds the limiter
this plugin's `setActive(false)` destroys the whole voice state. Whatever holds the limiter
flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in
@@ -221,12 +221,13 @@ There is no constant-reported-latency fallback — that option is closed, not sh
**this section does not shrink to a footnote and the limiter enable does not become
automatable.** Plan against the not-automatable classification; it is settled.
**One future condition could reopen it, and it is worth knowing about.** The restart is only
expensive because *this plugin's* `setActive(true)` re-decodes the WAV — not because the SDK
requires it. `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") files that
reduction, and **"the limiter enable is wanted automatable" is one of its named trigger
conditions.** If the parameter work genuinely needs that lane, the answer is to do the
decoupling first, not to re-litigate the classification.
**The decoupling that was filed against this section has LANDED (Γ-W3), and it changes the
cost but not the classification.** `setActive(true)` no longer re-decodes the WAV: the decoded
sample now survives a deactivate and only the voice state is rebuilt
(`instrument-control-surface.md` §7.11). So a flip costs a voice rebuild rather than a disk
read plus a decode — but **the deactivate still frees every sounding voice**, which is the
ground the not-automatable classification actually rests on. Plan against not-automatable; if
the parameter work wants that lane, the question to answer is the voice cut, not the decode.
---
@@ -334,11 +335,31 @@ invariant Θ-W1-T1 was run to establish.
> outranks anything the plugin sets, because the host replays it. That is inherent to
> automation and is not a defect to design away — but it has one sharp consequence for the
> resample bake, and that is §9.
>
> **Superseded by the paragraph immediately below.** "Outranks anything the plugin sets" reads
> as unbounded; the bounded formulation there — outranks only until the model has caught up,
> never a later restore/reset/knob move — is the correct one and the one `shell/instrument/
> CLAUDE.md`'s Authority section and `core/instrument/param/param_merge` implement. An unbounded
> hold was tried and is the specific defect this history keeps.
**[verify] at the track, before wiring:** that REAPER calls `setState` (not
`setComponentState`) on a single-component plug-in, and the ordering of `setState` against
the first `IParameterChanges` block after a project load. Verify against the vendored SDK
and in the DAW — do not build on the paragraph above without it.
**SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here
bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as
the GUI-update channel (*"should update the according GUI element(s) only"*,
`ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the
SDK's own `SingleComponentEffect` sample services BOTH
(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering
half dissolves with it, but only because the hold is BOUNDED: an automation point held by the
audio thread is re-applied over every merge until the UI thread folds it into the model, so a
lane that is genuinely driving outranks the restore whichever way round the two arrive, while a
lane that sent one point and had it folded does not. That is the authority rule read correctly —
and note it is reasoning from the host's replay behaviour, not a header quote: the SDK does not
state it. An unbounded hold makes the ordering claim true by making every later writer
permanently deaf, which is not the same property. `shell/instrument/CLAUDE.md`'s Authority
section is the model, and `core/instrument/param/param_merge` is where it is enforced.
**The audio thread cannot run the model path**
(`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches
the live block in place through one pure RT-safe function whose routing is pinned by an
exhaustive equivalence test against the model path.
### 6.2 The ID space: hand-assigned constants in one frozen table
@@ -499,7 +520,7 @@ will eventually propose "fixing" that. The answer is that the two *cannot* both
forever, and only one of the two axes holds still:
> **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor
> 840 → 980, and Γ-W3-T1 takes it to 1190 and re-rows every group into two categorical rows
> 840 → 980, and Γ-W3-T1 takes it to 1198 and re-rows every group into two categorical rows
> with a double-height MASTER. Within-row order is decided by *width fitting*, not by meaning.
> **Binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two
> drift apart** — and after the first drift the order is neither logical *nor* matching, which
@@ -825,7 +846,7 @@ already names that door from the other side.
> **A control is an exposed VST3 parameter if and only if its commit class is `Live` or
> `NoteOnLatched`.** Everything else is omitted from the parameter list entirely.
That makes `isLiveDeckParam` / `liveCommitFor` — already *"THE home for why each excluded
That makes `deckParamCommit` / `liveCommitFor` — already *"THE home for why each excluded
control is excluded"* — the single source for the parameter list too, which is the standing
rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE
place"*) applied once more rather than a second table opened beside it.
@@ -890,17 +911,26 @@ blob, which is exactly what §6.1's split is for.
set **for the note-on-latch reason** currently route through the reload tier, and the new
state fits them exactly:
- **Key-track**`isLiveDeckParam`'s header already says it *"feed[s] values a voice
- **Key-track**`deckParamCommit`'s header already says it *"feed[s] values a voice
latches at note-on by design (the pitch ratio…), so live delivery would retune… a note
already struck."* That sentence describes `NoteOnLatched`, not `Reload`.
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
Same shape.
**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the
predicate's own stated semantics — and it is what makes them automatable, since today they
would re-decode a WAV per automation point. **If either promotion is refused, that control
simply drops out of the parameter list.** The list follows the predicate; the predicate is
never bent to fill the list.
**RULED (Daniel, 2026-08-02): promote both.** Ids 1000 and 1450 issue; the count is 44 of 44.
The promotion aligns the routing with the predicate's own stated semantics — and it is what
makes them automatable, since otherwise they would re-decode a WAV per automation point.
**It was not the predicate-only change this section implied.** Key-track lives on
`InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000
would have no-oped in both directions with nothing failing to compile; both controls also had to
reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the
two latched values as arguments beside the rate. `param::valueHomeFor` is what makes the next
promotion of this shape a test failure instead of a silence, and it earns that claim in three
places rather than one: `test_param_live` asserts every exposed control HAS a home and that the
instance-scalar set has exactly two members, and the shell's own read and write paths
(`modelParamNormalized`, `writeDeckParamToModel`) now BRANCH on it rather than on a hardcoded
control id — so a third instance scalar cannot appear without failing that count.
**Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost;
that paragraph is the first thing to read if it is ever proposed.
+829
View File
@@ -0,0 +1,829 @@
# Render in place — product notes
Framing, rationale, and design-direction calls behind **Phase Ρ — render a track's
output to a new sibling track, in the timeline, without touching the bank.** The
tickable spec lives in `docs/PLAN.md` (§Phase Ρ); the architecture detail belongs in
`src/shell/capture/CLAUDE.md` and `src/core/capture/CLAUDE.md` once the track lands.
This doc holds the *why* — the third-verb argument that reconciles this feature with
the capture/placement separation, the prior art it borrows from, the reuse inventory
that makes it small, and the handful of decisions the shape actually turns on.
Status: framed by product-designer (2026-08-02) from Daniel's direct request the same
day; **all three [Daniel]-class forks RULED the same day**Ρ-F1 **refuse a
multi-track selection**, Ρ-F2 **the result track always goes to Arrange**, Ρ-F3
**follow the panel tail setting**. See §"Rulings" for the index and the recorded
rationale; each is specified in place in the section that owns it. Nothing in this
doc is open.
**Ρ-F2 overrode the framing, including Daniel's own original phrasing.** The request
as first stated had the new track take whatever mode was active when the action fired;
the ruling replaced that with an absolute rule. §"Mode transitions" is written against
the ruling, and the A/B-on-the-bench behaviour that mode-following would have produced
**no longer exists anywhere in this design** — do not reintroduce it as a benefit.
Everything else below is a product-designer call with its reasoning stated; contradict
it in review with an argument, not a preference.
---
## What it is (and what it is not)
**Render in place takes one selected track, renders its output over the current
range to a file, and drops that file as an item on a brand-new sibling track at the
exact position it was rendered from — then moves the source track into Design mode.**
The new track inherits the source's colour and its name with a `Capture ` prefix, and
belongs to Arrange unconditionally (Ρ-F2). The bank is never opened, never read, never
written.
The model Daniel named is REAPER's own *Render selected track time selection to new
track (stereo) and mute original*. Phase Ρ differs in exactly one respect, and that
respect is the whole feature: **instead of muting the original, it parks it.** The
source track goes to Design mode — hidden from the arrange, out of the mix, FX
offline, CPU reclaimed — and its rendered audio takes its place in the arrangement.
That is a strictly better disposition than mute, because mute leaves the design
scaffolding visible and its FX resident; Design mode removes both, reversibly, from
a snapshot.
**It is not a capture.** No `Sample` is minted into any `BankModel`, no index entry is
added, no file is recorded in the tracking ledger, the bank generation is not bumped,
and no live ReaSampler 9000 instance reloads. The bank does not change in any way an
observer could detect.
**It is not a freeze.** The source track's FX chain is untouched — not removed, not
bypassed permanently, not flattened. Design View's park is snapshot-based and fully
restored on toggle-back (`src/shell/view/CLAUDE.md` §Non-destructive restore), so
switching to Design brings the source back exactly as it was, FX and routing intact.
Ableton's *Freeze & Flatten* destroys the device chain; Phase Ρ never does.
**It is not a placement of a bank sample.** The insert action and the arrange drop
both take something already in the bank and put it on the timeline. Phase Ρ's file
was never in the bank and never will be. The two paths share `InsertMedia` and
nothing else.
---
## The third verb — and why the load-bearing principle survives it
Root `CLAUDE.md` carries the tool's sharpest rule:
> **Capture and placement are separate acts.** Capturing audio writes a file to the
> bank and adds an index entry. It **never** puts an item in the arrange view. […]
> Any code path that auto-inserts a capture into the timeline violates the purpose of
> the tool and **must be rejected in review**.
Phase Ρ renders audio, places an item in the arrange, and deliberately does not touch
the bank. The question is not rhetorical and the answer is not "it's fine because
Daniel asked for it."
**The answer is that the rule is about the bank, not about rendering.** Read the
sentence again: the object of "capturing" is *the bank* — a file in the bank folder
plus an index entry. The prohibition attaches to *that act* placing an item. What the
rule protects is a two-way boundary:
- the arrangement must never gain an item as a side effect of a bank gesture, and
- the bank must never gain a member as a side effect of an arrangement gesture.
Phase Ρ crosses neither direction, because **the bank is not a party to it.** The
system has two verbs today and gains a third:
| Verb | Source | Sink | Touches the bank |
|---|---|---|---|
| **Capture** (`RunCapture`, batch, realtime, bake, ingest) | arrange / instrument | bank | writes it |
| **Placement** (`RunInsertSelected`, `performArrangeDrop`) | bank | arrange | reads it |
| **Render in place** (Phase Ρ) | arrange | arrange | never |
Three verbs, three distinct (source, sink) pairs. The bank appears in exactly two of
them and never on both sides of one. The load-bearing rule is the statement that no
single verb may have the bank on one side and the arrange on the other *in the wrong
direction* — and Ρ has the bank on neither side.
What Ρ shares with capture is the **render**, not the capture: the same
`renderOffline` seam, the same `FxBypassGuard`, the same exact-bounds custom time
window, the same multi-track refusal, the same `RENDER_ADDTOPROJ = 0`. A render is a
mechanism; a capture is a render *plus* a bank landing. Ρ takes the mechanism and
declines the landing. That is reuse, not a breach.
### The boundary that keeps them from bleeding
Four things must stay true. Each is a review-rejectable condition, and three of the
four are structural rather than remembered:
1. **Ρ's shell never names the bank.** `render_in_place.cpp` must not call
`session.bank()`, `session.book()`, `session.recordCreated()`, or
`session.bumpBankGeneration()`. The `Sample` that `OfflineRenderBackend::capture`
returns is discarded, and on the project-media destination its `relativePath` is
left **empty** — so a Ρ `Sample` is inert by construction and could not be usefully
added to a bank even by accident.
2. **Ρ cannot express "write into the bank folder."** The destination reaches the
backend as a **two-valued enum** (`Bank` / `ProjectMedia`), never as a caller-supplied
path. There is no string a Ρ caller could pass that lands a file in
`reasampler_bank/`. This is the single most important structural choice in the
phase: it makes the boundary a type, not a convention.
3. **Ρ's file is never recorded as owned.** Prune deletes `(owned ∩ present)
referenced` (`src/core/reclaim/CLAUDE.md`), where `owned` comes from the tracking
ledger. Ρ records nothing, so its file is not prune-eligible — and it lives outside
the bank folder, so prune's enumeration never sees it either. Two independent
layers. The symmetry is worth stating plainly: **the tool deletes only what it
owns, and a render-in-place file belongs to the project, not to the tool.**
4. **The traffic is one-way.** Ρ may borrow capture's render. **Capture may never
borrow Ρ's placement.** No capture action grows a "…and place it" option, ever. If
a future request wants capture-and-place, the answer is "fire the capture action,
then fire the insert action" — two acts, which is the whole point.
**What would count as drift**, stated so a reviewer can name it: a `renderDir` string
on `CaptureRequest` instead of the enum; a Ρ path that calls `session.bank().add()`;
a Ρ file recorded via `recordCreated`; a `place` flag added to `CaptureActionDef`; or
a "Ρ but also add it to the bank" convenience action. Any of those collapses the
three verbs back into two and the rule stops meaning anything.
---
## Prior art, and what each one contributes
The shape is not novel; the *disposition of the source* is. Named precedents, because
they anchor the argument better than reasoning does:
- **Logic Pro — Bounce in Place.** The idiom Ρ's name borrows. Renders a track's
output to audio at the same timeline position, on a new track, with the source
preserved. Confirms that "in place" in DAW usage means *at the same timeline
position*, not *onto the same track* — which is why the name is right despite Ρ
creating a new track.
- **Pro Tools — Commit.** The closest prior art, and the one that validates the mode
transition. Commit offers four dispositions for the source track: *Hide and Make
Inactive* (the default), *Make Inactive*, *Delete*, and *Do Nothing*. The default
is hide-and-deactivate — visually gone and processing gone. That is precisely what
Design View's park already does (`B_SHOWINTCP=0`, `B_SHOWINMIXER=0`,
`B_MAINSEND=0`, `I_FXEN=0`, per-FX offline), except that Ρ gets it *reversibly and
as a membership fact* rather than as a per-track inactive flag. Ρ is Commit with
a fifth disposition the DAWs do not have — *move to the design bench* — supplied by
the tool's own mode system.
([Sound on Sound](https://www.soundonsound.com/techniques/making-commitments),
[Production Expert](https://www.production-expert.com/production-expert-1/pro-tools-track-commit-vs-track-freeze))
- **REAPER — Render selected track time selection to new track and mute original.**
The action Daniel named. Contributes the range semantics (time selection) and the
new-track placement; Ρ replaces its source disposition and adds colour/name
cloning.
- **Ableton Live — Freeze & Flatten.** Contributes a negative: flatten destroys the
device chain. Ρ explicitly does not, and the Design-mode park is what makes
preserving it cost nothing at playback.
---
## What already exists — the reuse inventory
Daniel's framing was that the machinery is in place. It substantially is. This table
is the proof, and it is also the spec's shape: each row names the module that answers
the need, so the implementation is composition rather than construction.
| What Ρ needs | Already answered by |
|---|---|
| Resolve the source track + the range (razor-else-time) | `shell/capture/scope_resolve``ResolveScopeSource(CaptureScope::Track, …)` |
| Refuse a multi-track render | `core/capture/render_settings``isMultiTrackStemRender` / `multiTrackRefusalMessage`, fired inside `renderOffline` |
| Render exactly the requested window, wet, at track scope | `shell/capture/capture_orchestrator``renderOffline` + `FxBypassGuard` + `RenderTrackSelection` |
| Never add the render to the project as an item | `shell/capture/capture.cpp``RENDER_ADDTOPROJ = 0`, unconditional |
| Refuse a widened render | `core/capture/render_window::frameCountFor` + the bounds gate in `OfflineRenderBackend::capture` |
| Snapshot and restore every `RENDER_*` project setting | `ScopedRenderSettings` (RAII) in `capture.cpp` |
| Force the project to be saved first | the `EnumProjects` / `Main_SaveProject` gate in `OfflineRenderBackend::capture` |
| Name the render after its source track + a discriminator | `core/capture/capture_name``composeCaptureName`, `shell/capture/capture.cpp``captureNameFor` |
| Read the source track's display name (with the `Track N` fallback) | `shell/capture/scope_resolve::trackName` |
| Collapse a bit-identical stereo render to mono | `core/capture/wav_codec::collapseToMono`, driven by `collapseCapturedFileToMono` |
| Compute the `InsertMedia` bitmask with the stretch bit provably clear | `core/capture/insert_plan::computeInsertMode` |
| Place a file at a known track + time, undo-wrapped, selection restored | the recipe in `shell/capture/insert.cpp` / `shell/actions/arrange_drop_win.cpp` |
| Move a track into Design and reapply the active mode | `core/view` `MembershipIndex::tag` + `shell/view/view.h` `applyMode` / `mintManagedLanes` |
| Persist the view model | `ReaSamplerSession::saveToActiveProject()` (the `persistViewState` pattern in `design_view_actions.cpp`) |
| Register one more bindable action | `shell/actions/action_registry` — one `ActionTableRow` in `main.cpp`'s table |
| Pure folder arithmetic over the flat `I_FOLDERDEPTH` delta list | `core/capture/track_topology` (extended — see §"The new track") |
**What genuinely does not exist**, and why nothing already there stretches to cover
it — three small pure additions and one bounded seam:
1. **A render destination that is not the bank.** `OfflineRenderBackend::capture`
derives its output path from `deriveBankPaths(projectDir, …)` unconditionally
(`capture.cpp`'s `OfflineRenderBackend::capture`) and points `RENDER_FILE` at the bank folder. Nothing about
that is parameterized. The alternative — render into the bank and then move the
file out — was rejected: it puts a transient, unindexed, unowned file inside the
folder prune enumerates, which is exactly the file class the ownership rule exists
to reason about, and it would make the bank folder momentarily lie about its
contents. **Seam:** a `CaptureDestination { Bank, ProjectMedia }` field on
`CaptureRequest` (defaulting to `Bank`), resolved by the backend *after* its own
save gate, plus a `RenderPaths deriveRenderPaths(absoluteDir, baseName, uniqueTag)`
sibling in `capture_paths` that `deriveBankPaths` is then expressed in terms of, so
the file-stem spelling keeps one owner.
2. **The absolute path of the rendered file, returned.** `CaptureResult` carries only
`sample.relativePath`, which Ρ deliberately leaves empty. One new field,
`CaptureResult::absolutePath`, set on the Ok path.
3. **Where a sibling track goes, in folder terms.** Genuinely new, genuinely
necessary, and genuinely small — see §"The new track".
4. **The idempotent `Capture ` prefix.** Six lines in `core/capture/capture_name`.
Everything else is composition. No new directory, no new backend, no new interface,
no new persisted state.
---
## The render — scope, range, refusal
**Scope is Track**, always. `CaptureScope::Track` means the render hears the item/take
FX plus the selected track's own track FX, with every ancestor and the master
neutralized to unity — no FX, no fader, no pan/width/law colouring
(`fxBypassPlanFor`, `FxBypassGuard`). That is exactly right for a drop-in
replacement: what the render contains is *the track's own contribution to its
parent*, which is what the new sibling track must reproduce when it feeds the same
parent.
**The range is razor-else-time selection**, resolved by `ResolveScopeSource` — the
same rule every other capture action already obeys. Razor wins when present; the
razor union's bounds are the window. If neither a razor area nor a time selection is
present, the action refuses with the reason `resolveRange` already produces. **Item
extent is not a fallback**, and should not become one: item extent is item scope's
concern, and a track render bounded by whichever items happen to be selected is a
different and much less predictable verb.
**Multi-track selections are refused**, inherited rather than re-implemented.
`renderOffline` fires `isMultiTrackStemRender` before touching anything, keyed on the
render *source* (`SelectedTracks`, which track scope always uses), so any selection of
more than one track refuses with `multiTrackRefusalMessage(CaptureScope::Track)`
before a single project setting is written. Ρ inherits this for free and adds no
check of its own.
**Ρ-F1, RULED by Daniel, 2026-08-02: *refuse*.** One selected track per fire is a
**settled non-goal**, in the same sense as the other entries in §"What Phase Ρ
explicitly is NOT" — not a deferral. There is no per-track loop planned, no second
wave holding one, and no seam to be left half-open on the way past. If multi-track is
ever wanted it is a new phase with its own framing, and the counter-argument that made
this a fork (the stem-collapse hazard does not apply to a per-track loop, so the
refusal is inherited rather than required) is recorded in §"Rulings" so it does not
have to be rediscovered.
**Tail follows the panel setting — Ρ-F3, RULED by Daniel, 2026-08-02: *"follow panel
tail settings."*** None / Auto / Manual, read from `bankPanelTailSetting()` like every
other capture path. Forcing `None` would be Ρ inventing a policy the rest of the tool
does not have, and a decaying design chain wants its tail when its source is about to
be silenced.
**Two consequences of that ruling, both accepted, both stated here because an
implementer meets them directly:**
1. **Under Auto or Manual the placed item is longer than the window it replaces.** The
chain's decay rings past the range end and the render carries it, so the item on
the result track extends past the source range. This is correct for reverb and
wrong for a section meant to butt against the next one — and the ruling accepts
that trade rather than splitting the behaviour. The user's lever is the panel's own
tail setting: set it to None before firing if a hard edge is wanted.
2. **The exact-bounds gate is inactive under Auto and Manual.** The gate in
`OfflineRenderBackend::capture` runs **only** under `TailMode::None`, so Ρ renders
in the other two modes are unguarded against a widened render. This is inherited
from every other capture path, not introduced by Ρ, and it is not a caveat to bury:
the phase's exactness guarantee under Auto/Manual is the *start* only.
The placed item's **start is exact in every tail mode**, because a tail is only ever
added at the end. The null test therefore holds in all three modes — the shared region
nulls; the tail simply has nothing to null against.
A third option was floated at framing and is **not ruled in**: keep the panel setting
but run the bounds gate's start-alignment check regardless of tail mode, since a tail
only ever extends the end. It is recorded as an unexercised alternative in §"Rulings"
and is not to be built into this phase.
**Mono collapse applies**, unchanged. A render whose channels are bit-identical
collapses losslessly to one channel and REAPER derives a mono item from the file
(`insert.cpp` passes only a path). Daniel's Ψ.6 ask named "mono arrange items"
explicitly, so this is the intended outcome, not a side effect. **But note what Ρ
changes about the risk:** root `CLAUDE.md` already flags, as `[verify — DAW]`,
whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a
dual-mono 2-channel item. Until Ρ, that property was unverified but not load-bearing
— nothing in the tool placed a collapsed capture automatically. **Ρ is the first path
where a collapsed render is placed into the mix by the tool itself,** which promotes
that question from a footnote to a verification obligation on this phase.
---
## Where the file goes
**The project's recording path** — `GetProjectPathEx(proj, buf, sz)` (SDK header
2550), which the header's own `RECORD_PATH` entry names as the way to get the
*effective* path when `RECORD_PATH` is blank or relative (header 3102).
Why there rather than a dedicated `reasampler_renders/` folder: because the file is
**the project's media, not the tool's.** REAPER's own render-to-new-track, apply-FX,
and freeze glue actions all write into the recording path; *Clean current project
directory* and *Save project as… with copy of media* both understand it. A file in
the recording path is managed by REAPER's project-media machinery, which is exactly
the machinery that should own it. A `reasampler_renders/` folder would be marginally
more findable and would make ReaSampler the apparent owner of files it explicitly
does not own — the wrong trade.
The relative-paths-only invariant is untouched: it binds the persisted `BankIndex`,
and Ρ writes nothing to any index. REAPER stores the item's source path in the `.rpp`
by its own rules.
---
## Placement — exactly
The item lands at **`src.startSeconds`**, the render window's start, unrounded and
**unsnapped**.
Unsnapped is the load-bearing word. `performArrangeDrop` runs its drop time through
`SnapToGrid` because a hand drop wants snapping; Ρ must not, because a snapped
placement would move the audio off the sample-accurate position it was rendered from
and break the property the whole tool is built on. **Ρ's placement is the null test
performed automatically:** an offline render of a range, re-inserted at its source
position, nulls to silence against the source — root `CLAUDE.md` calls that the
tool's trust anchor. Ρ *is* that gesture, made a workflow. If Ρ's placement is not
sample-exact, Ρ is broken, and the way you find out is by soloing the two tracks with
one polarity-inverted.
The recipe is `insert.cpp`'s, verbatim, with the bank lookup removed:
snapshot the cursor → `SetOnlyTrackSelected(newTrack)` → `SetEditCurPos(startSeconds,
false, false)` → `InsertMedia(absolutePath, computeInsertMode(InsertOptions{}))` →
restore the cursor. `InsertOptions{}` defaults give native length, no tempo conform,
and `insert_plan` guarantees the &4 stretch-to-time-selection bit is never set — so
"do not silently time-stretch on insert" holds by construction. Ρ must never offer a
conform variant: a conform would defeat the exact placement it exists to produce.
**Selection afterwards is a deliberate divergence.** Every other placing path
restores the caller's track selection. Ρ leaves **the new track selected, alone.**
The reason is specific: in the headline case the source track is being parked out of
sight in the same gesture, so restoring the selection would leave the user selecting
an invisible track. The new track is the workflow's next subject; select it. The edit
cursor *is* restored, since nothing about Ρ argues for moving it.
**The Ρ-F2 ruling inverts that reasoning in the Design-fired case, and the rule stays
absolute anyway.** Fired from Design, the result track is the parked one and the source
is the visible one, so leaving the result selected selects an invisible track — the
exact thing the rule exists to avoid in the other direction. Making the selection
conditional on the active mode would reintroduce mode-relative behaviour, which is
precisely what the ruling removed from this action. One rule, both cases: the result
track ends up selected, alone.
---
## The new track — index, folder, colour, name
### Index and folder — the one piece of genuinely new arithmetic
"Sibling" is easy to say and has three cases. Getting it wrong is audible, not
cosmetic, which is why this is the one place Ρ adds a real (small) pure function
rather than composing.
The naive answer — insert at `sourceIndex + 1` — is wrong twice:
- **Source is a folder parent** (`I_FOLDERDEPTH >= 1`). Inserting immediately after
it makes the new track the folder's **first child**, so the rendered audio is
summed back into the folder and runs through the parent's FX and fader a second
time. Track scope already put the parent's own FX and fader *into* the render, so
this double-processes audibly.
- **Source is the last track in its folder** (`I_FOLDERDEPTH <= -1`). The source
carries the folder's closing delta, so inserting after it lands the new track
**outside** the folder — the audio then bypasses the folder bus entirely and the
drop-in replacement is silently wrong in the other direction.
The correct rule is one computation in absolute nesting levels, over the same flat
`I_FOLDERDEPTH` delta list `track_topology::directChildIndices` already prefix-sums.
Given `depth[i]` for every track and `level[0] = 0`, `level[i+1] = level[i] +
depth[i]` (and `level[count] = 0` for a well-formed project):
1. `L = level[srcIdx]` — the source's own nesting level.
2. **Insert position** `p`: if `depth[srcIdx] >= 1` (folder parent), `p` = the first
`j > srcIdx` with `level[j] == L` — i.e. immediately after the whole folder, at
the source's own level; `count` if none. Otherwise `p = srcIdx + 1`.
3. **Two folder-depth writes**, and only two. With `b = p - 1` (the track that will
precede the new one) and `Lp = level[p]` (the level the track currently at `p`
sits at, `0` at end-of-project): set `depth[b] = L - level[b]`, and set the new
track's `depth = Lp - L`.
The total of all deltas is preserved, so nothing downstream of the insertion shifts.
Checked against every case:
| Case | `depth[b]` after | new track `depth` | Result |
|---|---|---|---|
| Normal track, mid-folder or top level | unchanged (`0`) | `0` | inserted directly below, same level |
| Last track in a folder (`-1`) | `0` | `-1` | new track becomes the folder's last member |
| Last in two folders (`-2`) | `0` | `-2` | closing delta moves to the new track intact |
| Folder parent | unchanged | `0` | new track lands after the whole folder, at the parent's level |
| Last track in the project | unchanged | `0` or the source's close | consistent, sums to zero |
That is roughly thirty lines, fully unit-testable with no DAW, and it belongs beside
`directChildIndices` in `core/capture/track_topology` — same input, same arithmetic,
same file. A malformed project whose deltas do not sum to zero should clamp rather
than assert; the failure mode is a track at the wrong nesting level, never a crash.
Creation is `InsertTrackInProject(proj, p, /*flags=*/0)` (SDK header 3954), then
`GetTrack(proj, p)` (3501) to obtain the handle. **`flags = 0`, not `1`:** the header
states `flags&1` adds default envelopes/FX, and a Ρ track must be bare — the FX are
already baked into the audio, and a default chain would process the render a second
time.
### Colour
`SetMediaTrackInfo_Value(newTrack, "I_CUSTOMCOLOR", (double)GetTrackColor(source))`.
`GetTrackColor` (3517) returns the custom colour already OR'd with `0x1000000`, or
`0` when the track has no colour set; `I_CUSTOMCOLOR` (2942) treats a value without
that bit as "not used." So the same single line clones a colour *and* clones the
absence of one, with no branch.
### Name
`"Capture " + sourceName`, where `sourceName` is `scope_resolve::trackName(source)`
`GetTrackName` (3629), which already answers REAPER's `Track N` convention for an
unnamed track. An unnamed track 7 therefore yields `Capture Track 7`, which is a real,
deterministic, identifiable name; this is Ψ-W2-T1's precedent applied unchanged.
Written with `GetSetMediaTrackInfo_String(newTrack, "P_NAME", buf, true)` (2997).
**The prefix is idempotent — it never stacks.** If the source name already begins with
`"Capture "`, the new name is the source name **verbatim**. So rendering `MONEY`
gives `Capture MONEY`, and rendering `Capture MONEY` gives `Capture MONEY` again, not
`Capture Capture MONEY`.
The alternative — a counter suffix, `Capture MONEY 2` — is rejected. REAPER does not
uniquify track names either, duplicate track names are ordinary and harmless, and a
counter is a treadmill that has to be maintained forever. What actually distinguishes
two renders of the same source is their position in the track list and the item on
each; the name's job is to say *what this is*, and it says that correctly the first
time. Making the operation a fixed point is worth more than distinguishability here.
This is one pure function in `core/capture/capture_name``captureTrackName(sourceName)`
— tested for the plain case, the already-prefixed case, the empty-source case, and
the `Track N` case. The prefix string is a display convention, not a persisted key:
unlike `kManagedLanePrefix` or an action-id suffix, changing it later strands nothing.
---
## Mode transitions — the source parks, the result goes to Arrange
Daniel's original phrasing was *"the source track stays/goes to design mode, and the
resulting new sibling track […] stays in whatever mode was active when the action was
run."* **The second half was overridden by his own later ruling — Ρ-F2, 2026-08-02:
*"for this action which is not a capture, the result track should always go to
arrange."*** The source half stands exactly as first stated; the result half is now
absolute and mode-independent.
**Source track: unconditionally a Design member afterwards.**
`membership().tag(sourceGuid, kDesignModeId)` covers both readings in one call —
`tag` replaces any prior single-mode membership, so a source already in Design
*stays* (no observable change) and a source in Arrange or untagged *goes*. This is
exactly what the shipped `VIEW_TAG_DESIGN` action does to a selection; Ρ performs it
on one track as part of a larger gesture.
Two inherited behaviours to state rather than fight:
- **Show-both on the source is not cleared.** Show-both is the user's explicit "pin
this visible across modes" flag. Ρ tagging a source into Design must not silently
unpin it; a show-both source stays visible in both stances, which is what the user
asked for.
- **A folder-parent source is not hidden by tagging it.** Parents are derived, never
tagged: a parent is visible in every mode any descendant leaf is visible in
(`core/view/CLAUDE.md`). Tagging a folder parent Design sets its *own* membership
but leaves it visible in Arrange as long as any child is an Arrange member. This is
a limitation of the shipped model that the existing tag action shares exactly; Ρ
inherits it. Do **not** invent a cascade that tags the children — that changes the
membership model to make one feature convenient.
**Result track: unconditionally an Arrange member**, whatever mode was active —
`membership().tag(newTrackGuid, kArrangeModeId)`, synchronously and explicitly, **not**
an `untag()` to the Arrange default. The distinction is load-bearing: an explicit
membership record is what protects the ruling from the auto-tag detector (below). An
untagged track is an Arrange member by observable behaviour but carries no record, and
a record is what the detector must be made to respect.
**Note what is novel here, because it is worth a reviewer's attention.** Ρ is the
**first** path in the tree to write an explicit `kArrangeModeId` record. The shipped
*tag selected tracks → Arrange* action does not — it dispatches to `doUntag()`, i.e.
Arrange-by-absence, which is why the constant appears in `view_mode_model` and
`mode_enable` but never in a `tag()` call. The record is nonetheless well-formed and
behaviourally identical to the untagged state everywhere that matters:
`ViewModeModel::isMember` answers `true` for `arrange` and `false` for `design` in
both cases, the derived-parent and lane rules read the same, and `untag()` still
returns it to absence. The only differences are one more entry in the persisted
membership index and — the point — its visibility to the detector's filter. **[verify
— DAW]** that a project saved with an explicit Arrange record round-trips and shows no
behavioural difference from an untagged track; the JSON round-trip is unit-testable and
should be tested, but the live view behaviour is not.
**The placed item is tagged Arrange too**, explicitly, for the same reason. Item
membership is a separate index from track membership and it is what drives lane
minting; leaving the item untagged hands it to the detector, which tags a new item to
the active mode whenever its track has no pre-existing single-mode content — and a
brand-new track never does. After `InsertMedia`, enumerate the new track's items (it
is brand new, so they are exactly the ones just placed) and tag each;
`shell/capture/item_read::itemGuid` is the existing GUID seam.
### The one hazard the ruling creates — the auto-tag detector
The ruling **dissolves** the ordering hazard the mode-following rule carried and
**creates a different one** in its place. Both are stated, because the first was
written into the plan's acceptance criteria and is now wrong.
**Dissolved.** Under mode-following, the tag had to precede the `applyMode` reapply:
an untagged track is an Arrange member by default, so a Design reapply running first
would have parked a track destined for Design, and it would have stayed parked until
the next switch. Under the ruling the result track *is* an Arrange member, so a Design
reapply parking it is the correct outcome and the ordering no longer changes what the
user sees. Tag first anyway, for state hygiene — it is simply no longer load-bearing.
**Created, and it is a defect rather than a nuance.** The panel's new-content detector
(`panel_input::detectNewContent` over `guid_diff::GuidBaseline`, feeding
`view_mode_model::autoTagNewContent`) tags **every new track to the active mode**,
unconditionally: it diffs live GUIDs against the previous tick and never consults the
membership index. Fire Ρ while Design is active and, on the next timer tick, the
detector re-tags the brand-new result track — and its item — from Arrange to Design,
silently reversing the ruling inside a second. Nothing in Ρ's own gesture can outrun
it, because it runs after.
**The fix is one rule, and it is worth stating generally: an explicit tag wins over
the detector.** `detectNewContent` drops any added GUID that already carries a
membership record (`MembershipIndex::query(guid) != nullptr`) before building its
auto-tag input. Two lines, at the point in that function where the model is already in
hand. The rule is right beyond Ρ — the detector exists to classify content the *user*
made, not content the tool made and has already classified — and it is a strict
improvement on an existing rough edge: a track brought back by undo keeps its original
mode instead of being re-tagged to whatever mode happens to be active at the time.
Two alternatives were considered and rejected. Re-arming the baseline after Ρ's
gesture (the `bankPanelNotifyProjectLoaded` mechanism) works, but it absorbs an entire
tick of genuinely new content silently — a blunt instrument aimed at two GUIDs.
Changing `autoTagNewContent`'s own track rule changes Design View's behaviour for
every caller in order to serve one.
The resulting behaviour, stated completely:
| Active mode when fired | Source afterwards | Result track afterwards | What the user sees |
|---|---|---|---|
| **Arrange** | Design — parked, hidden, FX offline | Arrange — visible, in the mix | The headline case. The design chain vanishes from the arrangement and its audio takes its place, at the same position, same colour, named after it. |
| **Design** | Design — visible on the bench | Arrange — parked while Design is active | The bench keeps only the source. The render is not on screen; it is waiting in the arrangement, and switching to Arrange shows it in the source's place. |
**The Design-fired case produces no visible change, and that is the ruling's accepted
cost.** Fire Ρ from the bench and the immediate feedback is that nothing appears. The
price buys the action meaning exactly one thing wherever it is fired — *commit this
design work into the arrangement* — which is the reasoning behind the ruling: Ρ is not
a capture, and a capture's mode-relative habits do not apply to it.
**Feedback deserves reconsideration for this case, and only this case.** Ρ is
otherwise silent on success because the new track is the feedback; fired from Design
there is no visible new track, so a silent success is indistinguishable from a no-op.
**[propose at review]** whether the Design-fired path should emit a one-line
`ShowConsoleMsg` naming the track it created. Recommendation: yes — it costs one line
and one string, and it is the only place in the phase where success is invisible.
**Lane minting runs**, via `mintManagedLanes(view, nullptr)` before the reapply, on
the same path `doMoveItems` already uses — so a track that ends up carrying content
for two modes splits into managed lanes exactly as it would from any other membership
change. Ρ adds no lane rule of its own.
---
## Undo
**One undo block** (`Undo_BeginBlock2` / `Undo_EndBlock2` with `UNDO_STATE_ALL`, i.e.
`-1`), opened before the track is created and closed after the mode reapply — the
same shape `insert.cpp` and `performArrangeDrop` already use. The render itself sits
*outside* the block: `renderOffline` mutates only `RENDER_*` project settings, which
it snapshots and restores by RAII, and writes a file. Nothing there is undoable and
nothing there should be in the undo history.
What one Ctrl-Z therefore restores: the new track is gone, its item with it, the
source track's folder-depth write is reverted, and the track selection is back.
Three residuals, all inherited and all honest:
1. **The rendered file survives.** REAPER's undo does not delete files, prune is the
exclusive deletion authority in this system, and Ρ's file is not even prune-
eligible. An undone render leaves an orphan `.wav` in the project's recording
path — precisely what REAPER's own render and record actions do. Not a defect.
2. **The source stays tagged Design.** REAPER's undo restores live track state but
does not roll back the view model's membership index or active mode — documented
in `src/shell/view/CLAUDE.md` §Gotchas, where `snapshots_` already carries the same
split. The way out is the existing *tag selected tracks → Arrange* action. Do not
build a compensating mechanism for one feature; the model-vs-undo split is a
phase-D-scale question, not Ρ's.
3. **A membership entry for the deleted track's GUID lingers**, harmlessly:
`ViewModeModel::reconcile(liveGuids)` prunes unknown GUIDs on its next pass.
**Persist runs after the block closes, not inside it** — the ordering
`design_view_actions::doMoveItems` already documents, because `persistViewState` may
raise a Save-As dialog and a modal dialog must not sit inside an open undo block.
---
## The action
**Command-id suffix: `RENDER_TRACK_IN_PLACE`.** FOREVER-STABLE per channel
(`channelCommandId` composes `CEREBELLUM_REASAMPLER_` / `CEREBELLUM_REASAMPLER_BETA_`
in front of it), so this string can never change once shipped — user keybindings key
off the composed id.
Chosen deliberately as a **new verb family**, not a member of `CAPTURE_*`. The id is
permanent and it is the most durable statement the codebase makes about which pillar
a feature belongs to; filing this under `CAPTURE_` would encode the exact confusion
the third-verb argument exists to prevent. `RENDER_*` also leaves room for a future
`RENDER_ITEMS_IN_PLACE` without renaming anything.
**Actions-list phrase: `"render selected track to a new track (source moves to
Design)"`**, which REAPER shows as *ReaSampler: render selected track to a new track
(source moves to Design)*. Long, but the parenthetical is not decoration — a user
binding this to a key must know the source is about to disappear from the arrangement
before they press it, not after. The existing family already carries parentheticals of
this weight (*insert selected sample at edit cursor (conform to tempo)*).
Registration is **one `ActionTableRow`** in `main.cpp`'s `buildMainActionTable()` — the
Q-W6 data-driven table drives registration, `hookcommand` dispatch, and the unload
mirror-unregister from that one row. Main section only; no `custom_action` /
`hookcommand2` second registration is needed.
**Feedback:** silent on success (the new track is the feedback), `ShowConsoleMsg` on
every refusal, carrying the reason `resolveRange` / `renderOffline` already produced.
This matches `RunInsertSelected` exactly.
---
## What Phase Ρ explicitly is NOT
Stated as sharply as the goals, because a small phase stays small only if its edges
are named:
- **Not a bank capture, in any form.** No index entry, no ledger record, no
generation bump, no instance reload.
- **Not multi-track.** Ρ-F1, RULED: one selected track per fire; more than one refuses
with the message that already exists. Settled, not deferred — no per-track loop is
planned, half-built toward, or left a seam for.
- **Not mode-following.** Ρ-F2, RULED: the result track is an Arrange member
unconditionally. There is no variant that follows the active mode, no per-fire
choice, and no bench-visible result — firing from Design puts nothing on the bench.
- **Not item-scoped.** No `RENDER_ITEMS_IN_PLACE`, no item-extent range fallback. The
seam is left open by the id family; the feature is not built.
- **Not a tempo-conforming insert.** No conform variant, ever — a conform would
destroy the exact placement the feature exists to produce.
- **Not a mute, not a delete, not a freeze.** The source keeps its items, its FX, its
routing and its automation. It moves stance; it loses nothing.
- **Not a source-track cascade.** Rendering a folder parent does not tag its children,
does not restructure the folder, and does not touch anything but the two
folder-depth values the insertion arithmetic requires.
- **Not a new persisted state.** Membership writes go into the existing `"reasampler"`
view section. Ρ adds no key, no version rung, no wire format.
- **Not a new directory.** Three small pure additions to existing `core/capture`
modules, one new shell TU in `shell/capture`, two lines in `panel_input.cpp`, one
row in `main.cpp`.
---
## Invariant amendments this phase owns
Three statements in the tree become false the moment Ρ lands, and amending them is a
**deliverable of the track**, not a follow-up — the precedent is Phase Ψ, where three
such amendments were carried as acceptance criteria of the tracks that broke them. A
track that lands Ρ without these reads as an invariant breach in review.
1. **`src/shell/capture/CLAUDE.md` §Invariants** — *"`RunInsertSelected` is the one
deliberate exception to capture-never-places … every other capture entry point
writes only a file + index entry."* Ρ adds a second placing path in this
directory. The amended form must say that this directory now hosts two placing
paths and state the discriminator: `RunInsertSelected` places a *bank sample*;
`render_in_place` places a render that never entered the bank. Neither is a
capture placing itself.
2. **`src/shell/actions/CLAUDE.md` §Invariants** — *"`arrange_drop_win` is the only
timeline-placing shell in this directory."* Strictly this stays true if Ρ's shell
lives in `shell/capture/`, but the sentence reads as a claim about the system.
Amend it to be explicit that it scopes to *this directory*, and cross-reference the
third verb.
3. **`src/core/view/CLAUDE.md` §Invariants** — *"New tracks are tagged to the active
mode at creation."* Ρ-F2 makes this conditional for the first time: the detector's
active-mode rule now applies only to a GUID that carries **no** membership record,
because an explicit tag wins over it (§"Mode transitions"). Amend the sentence to
say so, and state the reason in one clause — the detector classifies content the
user made, not content the tool made and already classified. `src/shell/panel/`'s
own CLAUDE.md describes `panel_input` as "the new-content auto-tag timer" without
restating the rule, so it needs no amendment; if that changes, the rule has one
home and this is it.
Root `CLAUDE.md` §"The load-bearing principle" should gain **one sentence**, not a
rewrite: that a render which never enters the bank and never leaves it is a third
verb outside the rule, with the two-way boundary spelled out. The rule's force must
not be diluted — it is what keeps the tool honest — so the amendment names the
exception precisely rather than softening the prohibition.
---
## Where it lives
**Pure** — three additions, all to existing modules with existing test targets, no new
directory:
- `core/capture/track_topology` — the sibling-placement arithmetic
(`siblingPlacement(depths, srcIdx) -> { insertIndex, precedingDepth, newDepth }`).
Same input list, same prefix-sum, same file as `directChildIndices`.
- `core/capture/capture_name``captureTrackName(sourceName)`, the idempotent prefix.
- `core/capture/capture_paths``RenderPaths` + `deriveRenderPaths(absoluteDir,
baseName, uniqueTag)`, with `deriveBankPaths` re-expressed over it so the stem
spelling keeps one owner (`bankRelativeForName` already depends on that being true).
**Shell** — one new TU plus one bounded edit:
- `shell/capture/render_in_place.{h,cpp}` — the action body. It lives in
`shell/capture/` rather than `shell/actions/` because it composes `renderOffline`
and `ResolveScopeSource` and is genuinely a render path, not a skin over one; the
directory's own CLAUDE.md says action *bodies* belong here and that `shell/actions`
only skins mutation logic owned elsewhere.
- `shell/capture/capture.cpp` — the destination branch (~6 lines at the path
derivation) and `CaptureResult::absolutePath`. **No behavioural change on the bank
path**: the enum defaults to `Bank`, and the bank branch must be byte-identical to
today.
- `shell/panel/panel_input.cpp` — two lines inside `detectNewContent`: drop added
GUIDs that already carry a membership record, so an explicit tag wins over the
auto-tag detector (§"Mode transitions"). This edit exists **only because of the Ρ-F2
ruling**; without it the ruling reverses itself on the next timer tick. No other
function in the file is touched.
- `src/app/main.cpp` — one `ActionTableRow`.
**Performance posture:** every surface is cold — one gesture, once. None of the named
hot paths (peaks envelope compute, audition, the realtime-capture tick's
single-pointer-test idle fast path, the instrument's `process()`) is touched, and no
guardrail applies beyond the general one.
---
## DAW-verification obligations
Following the plan's convention, stated up front so they are an obligation rather
than a discovery. Nothing in Ρ is unit-testable past the pure functions.
- **The null test on Ρ's own output** — render a track over a range, then
polarity-invert the source against the new track and confirm silence. This is the
phase's trust anchor and the single most important check.
- **The three folder cases** — a normal mid-folder track, a last-in-folder track, and
a folder parent — each rendered, each confirming the new track's nesting level and
that the render feeds (or bypasses) the folder bus correctly. `[verify — DAW]`
whether `InsertTrackInProject` at index `p` combined with the two `I_FOLDERDEPTH`
writes settles without an intermediate `TrackList_AdjustWindows(false)` (header
7735; the note at 2721 says some attribute writes need a manual panel update, and
the `isMinor` semantics are undocumented).
- **The collapsed-mono placement** — render a dead-centre source, confirm the item is
mono, and confirm it sums at the same level as the stereo source did. This is root
`CLAUDE.md`'s existing `[verify — DAW]` on mono-item-on-stereo-track summing,
promoted to load-bearing by Ρ.
- **Both mode transitions** — fired from Arrange (source parks; result track visible
and in the mix) and fired from Design (source stays on the bench; result track
parked, then present in the source's place after switching to Arrange). **In each
case wait out at least one panel timer tick and re-check the membership.** That is
the auto-tag-detector regression, and it is the check that catches a missing
explicit-tag-wins filter or an untagged item — either of which silently reverses the
Ρ-F2 ruling. The old ordering check (that the new track is never momentarily parked)
no longer applies: under the ruling the result track is an Arrange member and a
Design reapply parking it is correct.
- **Undo** — one Ctrl-Z removes the track and item and restores the folder depth; the
file survives; the source stays tagged Design.
- **The name and colour clone**, including a second run over an already-prefixed track
(must not stack) and an unnamed source (must read `Capture Track N`).
- **`GetProjectPathEx` on a project saved in a folder with a non-default recording
path**, confirming the render lands where the project's media lives.
---
## Rulings — Daniel's, 2026-08-02
All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
Nothing here is open. This section is an index; each ruling is **specified** in the
section that owns it, and that section is the implementation-binding text.
| Fork | Ruling | Specified in |
|---|---|---|
| **Ρ-F1** | **Refuse** a multi-track selection. One track per fire, inherited from `isMultiTrackStemRender`. A per-track loop is a settled non-goal for this phase, not a deferral | §"The render" — the multi-track paragraph; §"What Phase Ρ explicitly is NOT" |
| **Ρ-F2** | **The result track always goes to Arrange**, whatever mode was active. *"For this action which is not a capture, the result track should always go to arrange."* | §"Mode transitions" — the absolute rule, the item tag, and the auto-tag-detector fix it requires |
| **Ρ-F3** | **Follow the panel tail setting** (None / Auto / Manual), with both consequences accepted | §"The render" — the tail paragraph and its two numbered consequences |
**Ρ-F2 went against the framing, and against the request's own original wording.**
The framing recommended mode-following on the strength of Daniel's first phrasing
("stays in whatever mode was active") and of one use it enabled — an A/B of a chain
against its own render, both on the bench, neither touching the arrangement. The
ruling took the alternative the framing had itself named: an absolute rule, because Ρ
is *not a capture* and the mode-relative habit belongs to the capture pillar, not
here. **That A/B use no longer exists, and no text in this doc or in `PLAN.md` may
still claim it as a benefit.** The cost the ruling accepts is that firing Ρ from
Design produces no visible change; the compensating console message is a
[propose at review] item in §"Mode transitions".
**The ruling also has a cost the framing did not anticipate**, recorded here so it is
not read as scope creep at review: the panel's auto-tag detector tags every new track
to the active mode, so without an explicit-tag-wins filter the Design-fired case
reverses the ruling on the next timer tick. The two-line fix in
`panel_input::detectNewContent` is a **deliverable of this phase**, caused by this
ruling. Under mode-following it would not have been needed — the detector would have
agreed with Ρ.
**Ρ-F1 and Ρ-F3 both matched the recommendation**, so nothing in the spec moved. The
counter-arguments are kept because they are the reason each was a fork rather than a
call, and they will resurface: for Ρ-F1, that the stem-collapse hazard does **not**
apply to a per-track loop (each iteration renders one track), so the refusal is
inherited rather than forced — what a loop would actually cost is bookkeeping (indices
shift per insertion, so the folder arithmetic re-derives each pass; plus an undo label,
a partial-failure story, and a selection-afterwards rule). For Ρ-F3, that a drop-in
replacement arguably wants exact length, and that forcing `None` would have kept the
exact-bounds gate live on every Ρ render.
**One unexercised alternative, recorded and not built.** For Ρ-F3, a third option was
floated at framing: follow the panel setting *and* run the bounds gate's
start-alignment check regardless of tail mode, since a tail only ever extends the end.
It would recover the widening guard on the start edge without losing the tail, at the
cost of editing a gate shared by every capture path for one caller's benefit. It is
**not ruled in** and is not part of this phase. If the unguarded Auto/Manual render
ever produces a real bug, this is the shape of the fix.
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -45,7 +45,7 @@ below:
Two sharp edges follow directly and recur throughout this note:
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `main.cpp:41`,
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `app_version.h`'s `commandIdPrefix()`,
prefix `CEREBELLUM_REASAMPLER_`). Command-id strings are minted once and **never
changed after shipping** — user keybindings key off them. Two coexisting binaries
that register the *same* id strings collide in REAPER's Actions list.
@@ -67,9 +67,9 @@ allowed to touch.
## What we have today
- No version anywhere. `CMakeLists.txt:2` is `project(reaper_reasampler LANGUAGES
- No version anywhere. `CMakeLists.txt` is `project(reaper_reasampler LANGUAGES
CXX)` — no `VERSION`. The binary announces itself only as `"ReaSampler loaded.\n"`
to the console (`main.cpp:960`). There is no number a user, a bug report, or a
to the console (`main.cpp`). There is no number a user, a bug report, or a
future migration can key off.
- The natural user-visible readout already exists: the docked LICE bank panel, and
the console (`ShowConsoleMsg`). A version has cheap homes; none is wired.
+172
View File
@@ -0,0 +1,172 @@
# DAW verification — bank-package transfer across machines
What a DAW pass must establish for `.rsbank` export and import, and the exact strings or
counts to read off. The unit corpus (`tests/fixtures/package_compat/`) already proves the
version ladder, the truncation verdicts and the hostile-name refusals against frozen
bytes. **Nothing below is covered by it**: every cell here depends on a real REAPER
session, a real file dialog, or a genuine second machine.
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
load at startup only. Note the version the *About*/version action reports; §5 needs it.
**Machines to use.** Two: **A** (the source) and **B** (the destination). B must be a
different machine, or at minimum a different user account with its own REAPER resource
path and its own projects folder — the point is that no absolute path from A can resolve
on B. A USB stick, a network share, or a cloud folder are all acceptable transports.
**Projects to use.** On A: one **saved** project with a bank holding at least **three**
samples, at least one of them audibly distinct from the others, and at least one whose
display name carries a non-ASCII character (e.g. `Café hit`). On B: one **saved**,
otherwise empty project.
---
## 1. Export writes one file and touches nothing else
On A, right-click the bank's header in the docked panel → **Export as package...** (or
run *ReaSampler: export active bank as package*). Accept the suggested file name.
Read off:
- The console shows `ReaSampler export: wrote 3 entry/entries (N bytes) to <path>`, with
the entry count matching the bank.
- A single `.rsbank` file exists at that path. **No `.rsbanktmp` sibling remains** — a
leftover temp file means the atomic rename did not complete.
- The bank's card count, the bank folder's file count, and the project's dirty flag are
all **unchanged**. An export writes no ext state and opens no undo point, so REAPER
must not consider the project modified by it alone.
- Nothing was added to the arrange view.
## 2. The transfer itself — the claim no unit test can make
Copy the `.rsbank` to B by whatever transport you chose. Do **not** copy the project, the
bank folder, or anything else.
On B, open the empty saved project. Panel bank menu → **Import bank package...** (or run
*ReaSampler: import bank package (.rsbank)*), and choose the transferred file.
Read off:
- A message box: `Imported 3 sample(s) into a new bank: "<bank name>".`
- The console block repeats that line and ends with `One undo removes the imported bank
and its entries. It does NOT delete the imported files ...`.
- The panel shows a **new** bank with the same display name and the same number of cards,
**in the same order** as on A.
- B's bank folder holds three new files. The non-ASCII display name from A renders
correctly on the card — a mangled name here means the UTF-8 path/name conversion broke
in transit.
- **Audition each card.** They must sound like their counterparts on A. This is the whole
claim: the audio survived a machine boundary with no shared path.
- Press **Ctrl-Z once**. The imported bank and its entries disappear in one step. The
three files remain in B's bank folder (that is stated in the console block above, and is
the designed behaviour — a prune reclaims them). Redo to continue.
## 3. Re-importing the same package never overwrites
Still on B, import the **same** file a second time.
Read off:
- A second new bank appears, named with a suffix (`<bank name> 2`), and the box's
`(a bank named "<bank name>" already exists in this project)` clause appears in the
console block.
- B's bank folder now holds **six** files, not three. The console reports
`3 file(s) landed under a freshly minted name (the package's own name was already taken
in the bank folder). An existing bank file is never overwritten.`
- The first imported bank's cards still audition correctly — nothing was replaced under it.
## 4. Round trip back to the source
On B, export the imported bank (§1) to a second `.rsbank`. Carry it back to A and import
it into A's original project.
Read off:
- The import succeeds and lands as a new bank beside the original.
- The original bank on A is untouched: same card count, same names, same audio.
- Compare the two `.rsbank` files' **sizes**. They will usually differ — entry names,
sample ids and the export timestamp are all legitimately re-minted across a trip. The
payload bytes are what must survive, and that half is closed by
`tests/test_package_round_trip.cpp` against frozen bytes; do **not** treat a size
difference here as a defect.
## 5. The too-new refusal, with the message read verbatim
This is the direction a user hits when a collaborator is ahead of them, and the message is
the only actionable output. Produce it by hand:
1. Copy the `.rsbank` from §1 to a scratch name.
2. Open the copy in a hex editor. Bytes 03 are `RSBK`; bytes 47 are `formatVersion`
little-endian; bytes **811** are `minReaderVersion` little-endian.
3. Change byte **8** from `01` to `02`, and byte **4** from `01` to `02` (a writer cannot
require a reader newer than the format it wrote — leaving `formatVersion` at 1 makes
the file incoherent and it will be refused as malformed instead, which is a different
cell). Save.
4. Import the edited copy.
Read off — the message box, all four lines:
```
Cannot import this bank package.
It was written by ReaSampler <the version noted at the top> and needs package format 2 or newer.
This build (<the same version>) reads package format 1.
Nothing was imported. Install ReaSampler <the same version> or newer and try again.
```
- The writer version named is the one **this** build stamped in §1 (the hex edit does not
touch the semver), so the second and fourth lines will name your own version. That is
expected — what is being verified is that all three facts are present and the box
appears at all.
- **No** new bank, **no** new files in the bank folder, **no** undo point.
## 6. The truncated-download refusal is a different message
Copy the §1 package again and delete the last few hundred bytes (any hex editor, or
`head -c` / `fsutil` — the exact count does not matter as long as the file is shorter).
Import it.
Read off:
- The message box reads exactly: `This file is not a readable bank package (corrupt or
truncated). Nothing was imported.`
- It is **not** the §5 message. Crossing these two is the failure this cell exists to
catch — "install a newer build" does not fix a partial download.
- No new bank, no new files.
## 7. Corruption in the middle is caught before anything lands
Copy the §1 package again and flip a single byte **well past the halfway point** (inside a
payload, not the header). Import it.
Read off:
- The message box names the offending entry:
`This bank package is damaged (entry "<name>" failed its integrity check). Nothing was
imported.`
- The bank folder gained **no** files at all — not even the entries before the damaged
one. Verification runs to completion before the first write, so a damaged package costs
no rollback.
## 8. The unsaved-project refusals
- On B, File → New Project (do not save). Try to import. Read off:
`Save the project before importing a bank package -- an unsaved project has no bank
folder to import into.` The file picker must **not** have opened first.
- On A, in an unsaved project with no bank, try to export. Read off the console:
`ReaSampler export: save the project first -- an unsaved project has no bank folder to
read from.`
## 9. Drag-and-drop reaches the same verb
On B, drag a `.rsbank` from the file manager onto the docked ReaSampler panel.
Read off: the same import box as §2, and the same new bank. A `.rsbank` is a whole bank,
not audio — it must never land as an item in the arrange view.
---
## Recording the result
For each section, record **pass**, **fail with the string actually seen**, or **not
exercised**. §2 and §4 are the load-bearing ones: they are the only cells in this document
that involve a real machine boundary, and no unit test can stand in for them.
+28 -4
View File
@@ -11,9 +11,12 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
${REASAMPLER_SRC_DIR}/shell/capture/bake_landing.cpp
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_bounds_gate.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_in_place.cpp
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
@@ -24,6 +27,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_render.cpp
@@ -35,6 +39,7 @@ add_library(reaper_reasampler MODULE
${LICE_SRC}
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_fx_park.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
@@ -43,18 +48,37 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/design_view_actions.cpp
${REASAMPLER_SRC_DIR}/shell/actions/bank_actions.cpp
${REASAMPLER_SRC_DIR}/shell/actions/prune_action.cpp
${REASAMPLER_SRC_DIR}/shell/actions/package_export_action.cpp
${REASAMPLER_SRC_DIR}/shell/actions/ingest.cpp
${REASAMPLER_SRC_DIR}/shell/actions/arrange_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
# renders its own bake in its own process, which is what keeps the extension's link graph
# free of the voice engine a link edge to it here means the design drifted.
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The
# instrument renders its own bake in its own process, which is what keeps the extension's
# link graph free of the voice engine a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# The park surface's decisions are pure (header-inline, no REAPER types), so they are
# CTest-covered like a core/ module: the snapshot trust test, the refusal message and
# its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather
# than in a src/shell/view/CMakeLists.txt because that directory deliberately has none
# its TUs are compiled into this target directly. view_mode_model is linked for
# makeParkPlan, the flag set the trust test folds over; pinning that against hand-built
# ops would not catch a change to the planner.
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
# Bank-package import: the promptless verb plus its action skin. Kept as its own
# appended block rather than merged into the lists above, so the two package
# directions stay textually independent.
target_sources(reaper_reasampler PRIVATE
${REASAMPLER_SRC_DIR}/shell/package/import_bank.cpp
${REASAMPLER_SRC_DIR}/shell/actions/package_import_action.cpp)
target_link_libraries(reaper_reasampler PRIVATE import_landing package_pickers)
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
+37 -2
View File
@@ -26,11 +26,15 @@
#include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family
#include "shell/actions/insert_fx_action.h" // insert-as-FX action body
#include "shell/actions/package_export_action.h" // bank-package export action body
#include "shell/actions/package_import_action.h" // bank-package import action body
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
#include "shell/capture/bake_land.h" // resample-bake landing action body
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
#include "shell/capture/render_in_place.h" // render-in-place action body
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession
@@ -83,12 +87,18 @@ static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session)
static void RunInsertSelected(int arg) {
capture::RunInsertSelected(g_session, arg != 0);
}
// Placement of the PLAYER, not of an item: adds a ReaSampler 9000 to the selected track's
// FX chain. Sessionless — it reads the panel's selection, exactly like the drag it mirrors.
static void RunInsertAsFx(int) { reasampler::doInsertAsFx(); }
static void RunBatchCaptureItems(int) { capture::RunBatchCaptureItems(g_session); }
static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session); }
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
static void RunImportBankPackage(int) { reasampler::doImportBankPackage(g_session); }
static void RunShowVersion(int) {
// On-demand only — no unconditional startup print (routine console chatter pops
// the console window).
@@ -119,6 +129,12 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
rows.push_back({"INSERT_SELECTED_CONFORM",
"insert selected sample at edit cursor (conform to tempo)",
&RunInsertSelected, 1});
// A LOAD_*, not an INSERT_*: INSERT_SELECTED/_CONFORM place a timeline item;
// this loads an instrument onto a track's FX chain. The id is permanent, so
// keeping the two apart here is the most durable statement of which verb this is.
rows.push_back({"LOAD_INSTRUMENT_ON_TRACK",
"insert selected sample as ReaSampler 9000 on the selected track",
&RunInsertAsFx});
// One action fires N captures (per selected item / per razor area); the original
// selection is restored on every exit path. Bank-only, never places.
rows.push_back({"CAPTURE_BATCH_ITEMS",
@@ -134,12 +150,21 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
&RunCancelRealtime});
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource});
// A RENDER_*, not a CAPTURE_*: the id is permanent and is the most durable
// statement the codebase makes about which pillar a feature belongs to.
rows.push_back({"RENDER_TRACK_IN_PLACE",
"render selected track to a new track (source moves to Design)",
&RunRenderTrackInPlace});
// Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a
// stranded request can be landed by hand). The suffix is the wire contract itself —
// core/wire/bake_wire owns the spelling both artifacts read.
rows.push_back({reasampler::wire::kBakeActionSuffix,
"land pending ReaSampler 9000 resample bake",
&RunResampleBake});
rows.push_back({"EXPORT_BANK_PACKAGE", "export active bank as package",
&RunExportBankPackage});
rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)",
&RunImportBankPackage});
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows;
@@ -155,7 +180,13 @@ static void OnTimer()
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
g_session.poll();
// poll()'s undo/redo-reload and project-switch Load branches call
// loadFromProject, replacing g_session.view() wholesale — deferred here while
// an applyMode is on the stack (reachable if a pumped message loop re-enters
// this timer mid-apply), same retry-next-tick shape as the consume guard below.
// Deferred WHOLE, not just the reload branches: poll()'s identity tracking is
// cheap to skip for one tick and resumes correctly, unlatched, on the next.
if (!reasampler::modeApplyInProgress()) g_session.poll();
// persist stays MODEL-ONLY (loads the saved view model but does not apply
// visibility, to avoid coupling persist to the view shell); poll() raises a
@@ -164,7 +195,11 @@ static void OnTimer()
// signal re-arms the bank panel's new-content detector — notified BEFORE the
// reapply so re-arm and model restore ride the one load event (otherwise
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
if (g_session.consumeLoadSignal()) {
// modeApplyInProgress is tested BEFORE the consume, not after the applyMode call:
// the load signal is one-shot, a re-entered applyMode fails closed, and a reapply
// dropped here is the saved mode never applied at all. Holding the whole block
// back one tick also keeps the panel re-arm ahead of the reapply.
if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never
+1
View File
@@ -13,6 +13,7 @@ add_subdirectory(capture)
add_subdirectory(tracking)
add_subdirectory(reclaim)
add_subdirectory(version)
add_subdirectory(package)
add_subdirectory(view)
add_subdirectory(ui)
add_subdirectory(instrument)
+7 -2
View File
@@ -51,8 +51,8 @@ Detail specific to these pure modules:
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition``NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the one short-render diagnostic: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched on the retired custom-bounds mode, quoted by a refusal as a count coincidence and nothing more) and `isOnMillisecondGrid`, the whole-millisecond tolerance that count depends on.
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
@@ -79,6 +79,11 @@ Detail specific to these pure modules:
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
`src/shell/capture/CLAUDE.md`.
- **The custom-time-bounds field floors the render window to the millisecond; the
time selection does not.** Both observations and why only one bounds mode is
reachable: `render_settings.h`'s `kRenderBoundsTimeSelection` — the one narrative
home; this bullet is a pointer, not a retelling. Do not reintroduce
`RENDER_BOUNDSFLAG=0`.
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
FX-bypass-around-render mechanism, never by a render bit.
+15
View File
@@ -86,4 +86,19 @@ CaptureName composeCaptureName(const CaptureNameInputs& in) {
return out;
}
std::string captureTrackName(const std::string& sourceName) {
const std::string prefix(kCaptureTrackPrefix);
// A source with no readable name yields the bare word rather than a trailing
// space; both spellings are fixed points, which is what makes the whole function
// one (a track named exactly "Capture" must not become "Capture Capture"). Read
// from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names
// can't drift out of sync with each other (both expand from the same header token).
const std::string bare = kCaptureTrackPrefixBare;
if (sourceName.empty()) return bare;
if (sourceName == bare) return sourceName;
if (sourceName.rfind(prefix, 0) == 0) return sourceName;
return prefix + sourceName;
}
} // namespace reasampler::capture
+22
View File
@@ -59,4 +59,26 @@ std::string formatCaptureStamp(const CaptureStamp& stamp);
CaptureName composeCaptureName(const CaptureNameInputs& in);
// The single source of truth for the word itself — kCaptureTrackPrefixBare and
// kCaptureTrackPrefix below both expand from this one token, so editing it can never
// desync captureTrackName's "no readable source name" bare-word fallback from the
// separator-terminated prefix it is derived from.
#define REASAMPLER_CAPTURE_TRACK_WORD "Capture"
// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's
// no-readable-source-name fallback.
inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD;
// Prefixed onto a source track's name to name the track a render-in-place created.
// A display convention, not a persisted key — unlike a lane prefix or an action-id
// suffix, changing it later strands nothing.
inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " ";
// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on
// its own output, so a second render over a result track yields "Capture MONEY"
// again rather than "Capture Capture MONEY". A counter suffix is deliberately not
// offered: REAPER does not uniquify track names either, and what distinguishes two
// renders of one source is their position, not their name.
std::string captureTrackName(const std::string& sourceName);
} // namespace reasampler::capture
+22 -12
View File
@@ -45,16 +45,25 @@ std::string sanitizeStem(const std::string& baseName) {
return out;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
const std::string& baseName,
const std::string& uniqueTag) {
std::string stem = sanitizeStem(baseName);
if (!uniqueTag.empty()) {
stem += "_" + sanitizeStem(uniqueTag);
}
const std::string fileName = stem + ".wav";
RenderPaths r;
r.fileStem = stem; // stem only — REAPER appends the extension
r.fileName = stem + ".wav";
r.absoluteDir = normalizeSlashes(absoluteDir);
return r;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
// Precondition: caller must resolve a non-empty project directory — an
// empty one would otherwise fall back to a bare relative path (forbidden).
@@ -62,18 +71,19 @@ BankPaths deriveBankPaths(const std::string& projectDir,
// ignores it fails at the render/stat step, not silently onto CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
const RenderPaths r = deriveRenderPaths(
dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag);
BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder;
p.fileStem = r.fileStem;
p.fileName = r.fileName;
p.relativePath = bankRelativeForName(r.fileName);
p.absoluteDir = r.absoluteDir;
return p;
}
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// Same expression deriveBankPaths uses, so the two spellings can't drift.
return std::string(kBankSubfolder) + "/" + fileName;
}
+23 -3
View File
@@ -36,9 +36,29 @@ std::string normalizeSlashes(const std::string& path);
// "capture" if nothing usable remains. Deterministic.
std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture: baseName is the sanitized file-stem
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
// Where one render writes, with no index spelling at all: the directory REAPER is
// told to render into plus the stem/file name it produces there. `absoluteDir` is
// taken as given (normalized only) rather than derived, because a render that never
// enters the bank has no bank subfolder to append — the render-in-place verb points
// this at the project's own recording path.
struct RenderPaths {
std::string absoluteDir; // RENDER_FILE (forward slash, no trailing slash)
std::string fileName; // <stem>.wav
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// The file-stem spelling for one render: baseName is the sanitized file-stem source,
// uniqueTag an optional sanitized disambiguator (timestamp/counter) so repeated
// renders don't collide. Produces "<stem>[_<tag>].wav". THE one owner of that
// spelling — deriveBankPaths is expressed over it, and bankRelativeForName depends
// on the bank's spelling never drifting from it.
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
const std::string& baseName,
const std::string& uniqueTag);
// Derives the bank paths for one capture: the same stem spelling as
// deriveRenderPaths, in the bank subfolder, plus the project-relative path the
// index stores.
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
+20 -2
View File
@@ -30,7 +30,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
// nothing, so identical requests trim at the identical sample -> holds
// the bit-identical-repeats invariant.
t.tailFlag = kTailFlagCustomBounds;
t.tailFlag = kTailFlagTimeSelection;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
@@ -38,7 +38,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
case TailMode::Manual:
// Clamped to the cap regardless of source; negative floors to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailFlag = kTailFlagTimeSelection;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
@@ -100,6 +100,24 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
return c;
}
void applySecondPassRenderSettings(const bool secondPass, RenderSettingsChoice& choice) {
if (secondPass) { choice.settings |= kSecondPassRender; }
}
const char* renderSourceLabel(SourceMode mode) {
switch (mode) {
// MasterMix and TimeSelection share this label because they ARE the same
// render — see the header.
case SourceMode::MasterMix:
case SourceMode::TimeSelection: return "master mix";
case SourceMode::SelectedTracks: return "selected tracks via master";
case SourceMode::SelectedItems: return "selected media items";
case SourceMode::RazorArea: return "razor edits";
case SourceMode::Realtime: return "realtime record";
}
return "unknown"; // unreachable for a valid enum; never claim a source
}
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
switch (scope) {
case CaptureScope::Item:
+39 -7
View File
@@ -20,6 +20,7 @@ inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no sour
inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kSecondPassRender = 2048; // &2048 2nd Pass Render
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
@@ -27,17 +28,34 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e
// render wet; the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Render bounds mode -------------------------------------------------------
//
// A capture hands its window over on RENDER_BOUNDSFLAG=2 — the project's own TIME
// SELECTION (value verbatim, header ~3042), written through GetSet_LoopTimeRange.
//
// Custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS, header
// ~3045-3046) must NOT be reintroduced: REAPER resolved a custom-bounds window on a
// whole-millisecond grid AT RENDER TIME, floored the end, wrote the floored value back
// over RENDER_ENDPOS, and rendered exactly the floored frame count — twice, to the
// frame. Re-rendering on this mode came back exact on both edges, including a start
// carrying a sub-millisecond remainder, which is what locates the floor in the
// custom-bounds field rather than downstream in the render engine. This is the one
// narrative home for that; other sites point here.
inline constexpr int kRenderBoundsTimeSelection = 2;
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
// &(4<<16) = disable all render postprocessing (None/Manual path).
// RENDER_NORMALIZE (verbatim, header ~3051): &32768 = trim ending silence (Auto
// path); &(4<<16) = disable all render postprocessing (None/Manual path).
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
inline constexpr int kTailFlagNone = 0;
// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047): &4 is the
// time-selection mode's bit, the pair of kRenderBoundsTimeSelection above. A tail set
// under a different mode's bit renders no tail at all, so these two move together.
inline constexpr int kTailFlagTimeSelection = 4;
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
// ratio derives from this dB, never the reverse). Daniel-set.
@@ -65,7 +83,7 @@ enum class TailMode {
// normalize bit is set (Auto). The backend reads these straight onto
// GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the bounds mode's bit)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
@@ -96,6 +114,20 @@ struct RenderSettingsChoice {
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// Apply the 2nd Pass Render setting to the RENDER_SETTINGS block
void applySecondPassRenderSettings(const bool secondPass, RenderSettingsChoice& choice);
// The render source a mode drives, in words. Exists for the offline backend's
// bounds refusal: the two ways a render can miss its window — a source that
// derives its own bounds (selected items, razor edits) versus a time-bounded
// render that came up short — are indistinguishable from a frame count alone,
// and naming the source is what tells them apart in a bug report.
//
// MasterMix and TimeSelection deliberately answer the SAME words: they map to the
// same RENDER_SETTINGS value and render identically, so naming them apart would
// assert a render distinction that does not exist.
const char* renderSourceLabel(SourceMode mode);
// --- Capture scope: the FX-scope invariant ------------------------------------
//
// See src/core/capture/CLAUDE.md for the scope contract. There is NO master
+27 -2
View File
@@ -8,12 +8,20 @@ namespace reasampler::capture {
namespace {
// Round-to-nearest, so a position that sits mid-frame maps to the frame a render
// of it prints rather than to the frame below it.
// Round-to-nearest: the convention THIS module measures a window by, so a mid-frame
// position maps to the closer frame boundary rather than always down. Not a claim
// about how any renderer resolves that position -- see the header's caveat.
long long frameIndexAt(double seconds, int sampleRate) {
return std::llround(seconds * static_cast<double>(sampleRate));
}
// See the header for why whole milliseconds get a tolerance and why it is this small.
double floorToMilliseconds(double seconds) {
const double ms = seconds * 1000.0;
if (isOnMillisecondGrid(seconds)) return std::nearbyint(ms) / 1000.0;
return std::floor(ms) / 1000.0;
}
} // namespace
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
@@ -24,6 +32,13 @@ long long frameCountFor(double startSeconds, double endSeconds, int sampleRate)
return frames > 0 ? frames : 0;
}
bool renderHonoredBounds(long long expectedFrames, long long actualFrames) {
const long long delta = actualFrames > expectedFrames
? actualFrames - expectedFrames
: expectedFrames - actualFrames;
return delta <= 1;
}
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate) {
@@ -33,4 +48,14 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
}
bool isOnMillisecondGrid(double seconds) {
const double ms = seconds * 1000.0;
return std::fabs(ms - std::nearbyint(ms)) < 1e-6;
}
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate) {
return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate);
}
} // namespace reasampler::capture
+48 -6
View File
@@ -1,7 +1,8 @@
#pragma once
// render_window — pure frame arithmetic for a capture's requested window: the
// frame count a project-time range occupies, and whether a render whose bounds
// come from the selected items' own extent already prints that window.
// render_window — pure frame arithmetic for a capture's requested window: the frame
// count a project-time range occupies, whether a render whose bounds come from the
// selected items' own extent already prints that window, and the one diagnostic a
// refused render quotes — whether its shortfall matches a millisecond-floor coincidence.
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
namespace reasampler::capture {
@@ -13,11 +14,22 @@ namespace reasampler::capture {
// Returns 0 for a non-positive rate or an empty/inverted window.
//
// The offline backend compares this against the rendered file's own frame count, so
// exact-bounds failures surface as a refused capture rather than a wrong file. That
// REAPER resolves the two edges the same way is UNVERIFIED — a DAW pass decides
// whether the equality is exact or off by a frame.
// exact-bounds failures surface as a refused capture rather than a wrong file.
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
// True when a landed render's frame count is consistent with `frameCountFor`'s
// answer for the same window. Tolerates a one-frame difference, and exactly one.
//
// That bound is EMPIRICAL. It is provable only for renderer models that derive the
// count from the window's LENGTH (floor/ceil/round of (end-start)*rate) or resolve
// both edges by the SAME convention; a renderer that resolves the start edge and the
// end edge by DIFFERENT conventions can legitimately sit TWO frames from this answer
// (tests/test_render_window.cpp pins both facts). Which model REAPER uses is
// unverified, so a refusal one or two frames wide may be this gate's fault rather than
// the render's. Widening past one frame retires the exact-bounds invariant rather than
// relaxing it, and is not a fix to reach for before that question is answered.
bool renderHonoredBounds(long long expectedFrames, long long actualFrames);
// True when a render bounded by the selected items' own extent
// [itemStart, itemEnd) already prints exactly the requested
// [reqStart, reqEnd) window — the one case where REAPER's selected-items render
@@ -30,4 +42,34 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate);
// --- Diagnostics: where a short render lost its frames ------------------------
// The frames this window would hold if its END were resolved on a whole-millisecond
// grid, floored, instead of exactly. That is what REAPER's offline render did on the
// retired custom-time-bounds mode (render_settings.h's kRenderBoundsTimeSelection states
// the whole observation): two live short renders (48 kHz, TailMode::None) printed this
// count to the frame. Kept as the refusal's shape check — a refused render matching it
// says the floor is back, on a mode that was measured escaping it.
//
// Still a DESCRIPTION, never a request: nothing renders from this number and no capture
// path asks for it — a refusal quotes it to say the shortfall has the known shape, which
// is not the same as proving that this particular render took it. Whole-millisecond values
// are recognized within a nanosecond, because a decimal millisecond is not always one
// in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a
// millisecond from a window already on the grid. A nanosecond is far under one frame
// at any rate we render, so a real sub-millisecond remainder still floors.
//
// The tolerance is ours, not REAPER's: on a `1.007`-class grid point, a REAPER floor
// that does NOT carry the same epsilon would miss this shape entirely, and a real
// floored render would then read as an unmatched short render rather than the known one
// — silence here is not proof the floor didn't happen (docs/TODO.md records why this
// premise needs a DAW measurement before anything is built on it).
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate);
// True when `seconds` sits on a whole-millisecond boundary, under the nanosecond
// tolerance msFlooredEndFrameCount depends on and for the reason stated there. Public so
// that premise is testable directly rather than only through the count it feeds.
bool isOnMillisecondGrid(double seconds);
} // namespace reasampler::capture
+12 -2
View File
@@ -18,6 +18,10 @@ TailMode cycleTailMode(TailMode current) {
return TailMode::None; // unreachable for a valid enum; fail to the safe default
}
bool toggleSecondPass(bool current) {
return !current; // wow
}
double clampManualMs(double manualMs) {
return std::clamp(manualMs, 0.0, kMaxTailMs);
}
@@ -77,6 +81,7 @@ std::string serializeTailSetting(const TailSetting& setting) {
json::Writer w(out);
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
w.keyRaw("secondPass", json::boolToStr(setting.secondPass));
} // Writer closes the object here (see bank_model's NRVO note)
return out;
}
@@ -87,7 +92,8 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
int modeInt = 0;
double ms = 0.0;
bool haveMode = false, haveMs = false;
bool secondPass = false;
bool haveMode = false, haveMs = false, haveSecondPass = false;
r.skipWs();
if (!r.consume('}')) {
do {
@@ -99,13 +105,16 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
} else if (key == "manualMs") {
if (!r.parseDouble(ms)) return std::nullopt;
haveMs = true;
} else if (key == "secondPass") {
if (!r.parseBool(secondPass)) return std::nullopt;
haveSecondPass = true;
} else {
if (!r.skipValue()) return std::nullopt; // forward-compat
}
} while (r.consume(','));
if (!r.consume('}')) return std::nullopt;
}
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
if (!haveMode || !haveMs || !haveSecondPass) return std::nullopt; // absent key -> malformed -> default
const std::optional<TailMode> mode = modeFromInt(modeInt);
if (!mode) return std::nullopt;
@@ -113,6 +122,7 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
TailSetting out;
out.mode = *mode;
out.manualMs = ms;
out.secondPass = secondPass;
return out;
}
+7 -2
View File
@@ -21,14 +21,19 @@ inline constexpr double kManualStepMs = 250.0;
// The panel's current tail setting: mode + the length used only when Manual.
// Default None so a capture with no explicit choice stays exact-bounds.
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
// 2nd-Pass Render optional, off by default
struct TailSetting {
TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs;
TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs;
bool secondPass = false;
};
// Cycles the tail mode: None -> Auto -> Manual -> None.
TailMode cycleTailMode(TailMode current);
// Toggles the 2nd-Pass Render
bool toggleSecondPass(bool current);
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
double clampManualMs(double manualMs);
+34
View File
@@ -25,4 +25,38 @@ std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
return children;
}
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex) {
const int count = static_cast<int>(folderDepths.size());
if (count == 0) return SiblingPlacement{};
const int src = srcIndex < 0 ? 0 : (srcIndex >= count ? count - 1 : srcIndex);
// levels[i] is track i's absolute nesting depth; levels[count] is the depth the
// list closes at (0 in a well-formed project). Negative is unrepresentable, so a
// malformed over-closing delta clamps here rather than propagating.
std::vector<int> levels(static_cast<std::size_t>(count) + 1, 0);
for (int i = 0; i < count; ++i) {
const int next = levels[static_cast<std::size_t>(i)] +
folderDepths[static_cast<std::size_t>(i)];
levels[static_cast<std::size_t>(i) + 1] = next < 0 ? 0 : next;
}
const int L = levels[static_cast<std::size_t>(src)];
int p = src + 1;
if (folderDepths[static_cast<std::size_t>(src)] >= 1) {
p = count; // an unterminated folder swallows the rest of the list
for (int j = src + 1; j <= count; ++j) {
if (levels[static_cast<std::size_t>(j)] == L) { p = j; break; }
}
}
SiblingPlacement out;
out.insertIndex = p;
out.precedingIndex = p - 1;
out.precedingDepth = L - levels[static_cast<std::size_t>(p - 1)];
out.newDepth = levels[static_cast<std::size_t>(p)] - L;
return out;
}
} // namespace reasampler::capture
+35 -3
View File
@@ -1,8 +1,8 @@
#pragma once
// track_topology — pure folder arithmetic over a project's track list: which tracks
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
// tests/test_track_topology.cpp.
// are the DIRECT children of a folder parent, and where a new SIBLING of a given
// track goes, both derived from the I_FOLDERDEPTH deltas alone. NO REAPER types
// (the shell reads the deltas); unit-tested by tests/test_track_topology.cpp.
#include <vector>
@@ -21,4 +21,36 @@ namespace reasampler::capture {
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
int parentIndex);
// Where a new track goes so it is a SIBLING of `srcIndex` — same nesting level, same
// folder — and the two I_FOLDERDEPTH writes that put it there.
struct SiblingPlacement {
int insertIndex = 0; // the index the new track occupies after insertion
// The track that will PRECEDE the new one (insertIndex - 1), and its rewritten
// delta. -1 only for a degenerate empty list, where there is nothing to write.
int precedingIndex = -1;
int precedingDepth = 0;
int newDepth = 0; // the new track's own I_FOLDERDEPTH
};
// Both naive answers are audibly wrong, which is why this is arithmetic and not
// `srcIndex + 1`: inserting straight after a folder PARENT makes the new track that
// folder's first child (its audio re-enters the parent's FX and fader), and inserting
// straight after the folder's LAST track steals that track's closing delta and drops
// the new one outside the folder entirely (its audio bypasses the folder bus).
//
// Levels are absolute nesting depths recovered from the deltas (level[0] = 0,
// level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first
// following track back at the source's own level — i.e. after the whole folder;
// everything else inserts directly below the source. On a well-formed delta list
// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no
// track after the insertion changes level — the malformed case below does not carry
// that guarantee; the clamp keeps the result legal, not level-preserving.
//
// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to
// the nearest legal placement rather than asserting: the failure mode of a corrupt
// project must be a track at the wrong nesting level, never a crash.
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex);
} // namespace reasampler::capture
+18
View File
@@ -6,6 +6,7 @@
#include <cstdio> // std::snprintf (hash hex render)
#include <cstring> // std::memcpy, std::memcmp
#include <utility> // std::move
namespace reasampler::capture {
@@ -300,6 +301,23 @@ MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
return out;
}
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes) {
CollapsedWav out;
MonoCollapse collapse = collapseToMono(bytes);
if (collapse.collapsed) {
const WavLayout rebuilt = parseWavLayout(collapse.bytes);
if (rebuilt.valid) {
out.bytes = std::move(collapse.bytes);
out.layout = rebuilt;
out.collapsed = true;
return out;
}
}
out.bytes = std::move(bytes);
out.layout = parseWavLayout(out.bytes);
return out;
}
std::string monoCollapseSuffix(MonoCollapseOutcome outcome) {
switch (outcome) {
case MonoCollapseOutcome::Declined: return {};
+16
View File
@@ -117,6 +117,22 @@ struct MonoCollapse {
// including the bext/source-position consequence beyond hashing.
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
// A buffer after the collapse has had its say, PAIRED with the parse of the bytes
// actually returned — so a caller that hashes `bytes`, reads a channel count off
// `layout` and then writes `bytes` cannot describe one buffer while writing another.
struct CollapsedWav {
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV, or the input verbatim
WavLayout layout; // the parse OF `bytes`
bool collapsed = false;
};
// `collapseToMono` over a whole buffer, for a caller that goes on to hash and measure
// the result rather than rewrite a file (`shell/capture`'s collapseCapturedFileToMono is
// the file-side path over the same predicate). Takes the buffer by value: a decline hands
// those same bytes straight back. A rebuild that does not parse back is discarded rather
// than returned, so an invalid `layout` can only ever mean the INPUT was not a usable WAV.
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes);
// How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own
// "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did
// not land. The capture is intact and correctly measured in every case — only the report
+68 -27
View File
@@ -1,8 +1,8 @@
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui)
## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
subdirectories:
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
@@ -17,6 +17,11 @@ subdirectories:
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
window, the offline render over a voice engine built for that render alone, and the
ratified post-bake reset. See `bake/CLAUDE.md`.
- **`param/`** — what the instrument tells a VST3 host about its automatable parameters,
with no VST3 type in it: the FOREVER-FROZEN id table, the exposed set derived from
`deckParamCommit`, the plain-value layer, and the one formatter per unit category. Sits
ABOVE `ui/` — the list is a function of the commit predicate, never the reverse. See
`param/CLAUDE.md`.
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
interiors, waveform, keyboard strip, capture browser, param controls, envelope
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
@@ -171,9 +176,10 @@ Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other
must live compute, latching the parameters at note on is not acceptable. long term these will be
automatable parameters."* It rejects the precedent, not one instance of it.
- **Which controls are live is ONE decision, recorded in ONE place**`isLiveDeckParam` and
`liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and
why each exclusion is excluded — see there rather than restating the list here.
- **How a control reaches the audio is ONE decision, recorded in ONE place**`deckParamCommit`
and `liveCommitFor` (`ui/deck_groups`), a THREE-state classification (`Live` /
`NoteOnLatched` / `Reload`) whose header is THE home for where each control sits and why —
see there rather than restating the list here.
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
block the shell owns per instance. The member-ordering constraint that enforces it, and why,
are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
@@ -212,9 +218,14 @@ automatable parameters."* It rejects the precedent, not one instance of it.
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
Never persisted; it selects what is drawn, not what is played.
transient editor choice: **clicking anywhere in an envelope deck (amp, pitch, filter) — panel,
knob or button — focuses that deck's overlay**, and the focused deck takes an `AccentPrimary`
border. At most one is overlay-active, and **none is a valid resting state — the editor opens
there**, reached by clicking a control surface outside the three decks. Focus SETS rather than
toggles, so editing the deck you selected cannot deselect it; the overlay itself neither steals
nor clears focus for the same reason. Never persisted; it selects what is drawn, not what is
played. *(The per-deck corner radio this replaced is gone; MASTER keeps the corner slot for its
passive gain-reduction lamp.)*
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
@@ -283,19 +294,40 @@ anything for a trigger shape.
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
- `voice_engine.h` / `voice_engine.cpp``VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher**`writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids.
- **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry exactly** (`periodAlignedJump`, `pitch_shift.h`); do not "simplify" that fallback into an approximation of it.
- `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN:
a decimated cumulative-mean-normalized difference picks the period, the full-rate difference
function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole
number of it. **Runs off the audio thread by link graph** (`period_detect.h` is the one home
for that invariant) — the same shape as the extension's link graph not gaining the voice
engine. Its one caller is the loader (`map/sample_map`'s
`buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period
is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no
rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony,
percussion and a source whose period changes mid-sample all return it, and the shifter's
fixed-window geometry is the documented fallback. **Detection analyses the SUSTAIN LOOP when
the capture carries one long enough to host the full search band** (`periodAnalysisSpan`),
otherwise the whole source: the loop is what a Gate voice asymptotically plays, and a phrase
whose head is pitched differently from its sustain would otherwise disagree its way to none.
A shorter loop analyses the whole source rather than a narrowed band — a narrower span may
never buy itself a higher lowest-findable fundamental.
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob, the processor multiply and the host's `toPlain` so the needle, persisted value, audio multiply and reported dB cannot drift. Math only — the dB label is `param/param_format`'s, so the editor and the host cannot print it two ways.
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked 0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v14), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
@@ -304,39 +336,48 @@ anything for a trigger shape.
### `ui/`
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
- `sample_bands`**THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
- `sample_bands`**THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect, plus generic named draggable markers with a drag-delta resolver, clamp, and the zero-crossing snap. The snap is RADIUS-BOUNDED (`snapToZeroCrossing`), the radius denominated in PIXELS and converted through `xToFrame`; `nearestZeroCrossing` beside it is the unbounded primitive and is NOT what a drag applies. Ctrl defeats the snap outright at the shell (`editor_input_waveform.cpp`), the same "get out of the way, I want exactness" role it plays on the knobs' fine drag. `markerHandleRect` is a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable the column goes to the first in draw order, the tab, asked first, resolves the other.
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the band's DRAWN COLUMN SPAN at the band's full height, in both modes: same inset and same width as `component_geometry`'s `waveformColumnCount`, so overlay pixel and waveform column are one pixel and every overlay lands on the column that draws the frame it names. Deriving that width instead of restating the inset is what keeps the two chains from drifting apart again. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. Which of them are PRESENT is the mode's answer, and drawn iff grabbable: Trigger carries START alone (the loop pair and the fade are absent, not dim), Gate always carries the pair so the drag-to-set-loop gesture survives the enable being off, and the fade needs an active loop to have a seam. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `param_slider`parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `param_taper`THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. **The cell/knob/label sizes and `sample_bands`' editor floor move as a pair** — wider cells need a wider floor width or the deck wraps to a fourth row. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has fourteen pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, and the cells present are CENTRED in it at their natural `kDeckCellW`**: a `-1` id reserves one cell's width without a cell, and the reserve is paid for in symmetric end margins, never in wider cells. That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or stretching the knobs off their pitch; only the reserve COUNT matters, so where a `-1` sits in `cellIds` is free. Two rejected laws, recorded so they are not re-proposed: dropping the reserves (reflows every neighbour on a mode flip, which is what the reserves exist to prevent), and dividing the run between the cells present (the retired law — it handed a reduced face 90100 px cells). A SPANNING group is the one place a reserve's position still matters, because there it advances a fixed slot.
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position maps and their clamps),
`resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
two stage times are comparable at a glance. A display-unit decision only — nothing about the
stored representation changes. Links the header-only `play_seconds`, deliberately not
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over
`param_taper`'s maps), `resetDeckParam` (the double-click reset — the defaults are READ off a
default-constructed `PlaySeconds`, so there is no second table of defaults to drift, and the
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s
`param_format` owns the one formatter per unit category, because the host and the editor must
be its two callers and neither may hold a second implementation. Links the header-only
`play_seconds`, deliberately not
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/12/24/36/48/60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` — the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone. Also owns the editor's two paint-dispatch predicates, so neither lives as a bare comment in the shell: `meterFastPathEligible` (is a WM_PAINT dirty rect wholly inside the field?) and `meterBarsWithinField` (the self-containment invariant that fast path rests on), both asserted in `test_master_meter.cpp`.
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `overlayEnvForGroup`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-FOCUS state machine (which deck names which envelope, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the 92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `curve_tessellate` — the staged envelope's TRACE, split from `envelope_overlay` on the axis those two already have: that module decides where a node LANDS, this strokes the span BETWEEN two of them. Joins the non-knot vertices with the curve each stage's exponent defines, sampled one point per pixel column, at `start + (end - start) * curveMap(phi)` — the composition `envelopes.h`'s four evaluators use, so a drawn stage and the sound it makes cannot diverge. Node vertices keep their exact integer coordinates (the handles are drawn on them); only the interior samples are sub-pixel. A neutral exponent or a zero level span emits the two endpoints and nothing between, which is the straight stroke drawn before curves existed, vertex for vertex.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
## Gotchas
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned, and it is not linear in seconds either** — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by `param_taper`'s own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse.
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
+2
View File
@@ -2,6 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map)
add_subdirectory(note)
add_subdirectory(ui)
# After ui: the VST3 parameter identity reads the deck's commit predicate and its value binding.
add_subdirectory(param)
# Last: bake composes the three above it.
add_subdirectory(bake)
+36 -7
View File
@@ -21,8 +21,21 @@ decision about what the render made obsolete.
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
even a pathological envelope cannot run past the window.
- **The whole signal chain is printed, master gain included** — the gain multiply in
`bake_render.cpp` carries the argument for why.
- **The whole chain is printed — voice, master gain, then the limiter, in the processor's
own order.** `bake_render.cpp`'s master stage carries the argument. The limiter is printed
only when it is ENGAGED; bypassed, `renderBake` never constructs one and the result is the
pre-limiter render frame for frame. The lookahead is compensated inside the render — the
buffers carry an extra flush window and the capture is read past it — so an engaged bake
under the ceiling is bit-identical to a bypassed one, not the same audio 2 ms late.
- **A printed capture replayed through an engaged limiter is limited TWICE — a NAMED
boundary, not a bug**, and the same shape as the automation-lane limitation below. The
reset is what normally prevents it (`limiterEnabled` is not on the survive list, so a bake
hands the enable back off), and at unity the second pass has nothing to take: every sample
of the printed file is already at or under the ceiling, and the limiter reduces only where
its detector reads ABOVE it — which after a bake means its inter-sample estimate alone. Dial
the enable back on over raised gain, though, and the capture is limited on top of limiting
that is already in its samples. Not detectable from inside the instrument and not corrected
there; the user's remedy is to leave the enable where the bake put it.
- **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a
collapsed window, a non-positive rate, a window that rounds to no frames, and one past
`kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
@@ -50,6 +63,19 @@ decision about what the render made obsolete.
- **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one
classification this track made against the ratified rule rather than reading off it.
`bake_reset.cpp` carries the argument at the assignment.
- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list
candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has
no disposition to classify against the ratified reset rule.
- **A host automation lane outranks the reset, and the bake cannot clear it — a NAMED
limitation, not a bug.** Every reset-class value that is also an exposed VST3 parameter is
now notified to the host (the reset writes through `setInstrumentParams`, which is the one
notification funnel), so the host's DISPLAY follows the reset. A lane, however, lives in the
host's project data: if a reset-class parameter carries one, the host replays its curve onto
audio that already has that processing baked in — double processing, and the "sounds as the
dialled instrument sounded just before the click" claim does not hold in that case. There is
no detection available: `IAutomationState` reports the host's automation mode for the whole
plug-in, not per parameter, so both "refuse the bake" and "reset only the un-automated ones"
are unbuildable rather than merely unattractive. The user's remedy is to remove the lane.
## Modules
@@ -58,7 +84,8 @@ decision about what the render made obsolete.
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
`planBake`, the one `ResolvedNote` + rate -> frames resolution, answering a `PlannedBake`.
- `bake_render``BakeAudio` and `renderBake`: the programmed note through the sample's
own voice path, summed into an interleaved buffer at the source's own channel count.
own voice path and then the master stage, summed into an interleaved buffer at the
source's own channel count.
- `bake_reset``BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
both the parameter set and the post-mixer master gain.
@@ -67,10 +94,12 @@ decision about what the render made obsolete.
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
offset from each other whenever the note and the capture window do not start together.
`bake_plan.h` says which field is in which; do not read them as one clock.
- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch
offset makes the read head take longer to cross its span, so the window is scaled by the
deepest downward offset the voice can reach — a shallower excursion leaves trailing silence
in the file. Both the Trigger span and the Gate exhaustion length take it.
- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that
slows the read makes the head take longer to cross its span, so the window is scaled by the
slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed
alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length
take the product, and the Gate-with-loop branch takes neither.
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
are live, so the velocity is a property of the sound being printed and not a detail of the
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
+6 -2
View File
@@ -6,9 +6,11 @@ reasampler_pure_library(bake_plan
LINK PUBLIC note_program sampler_core trigger_seam)
reasampler_test(bake_plan LINK bake_plan)
# limiter beside sampler_core, not through it: the render prints the whole master stage, and
# the limiter runs on the summed output rather than inside a voice.
reasampler_pure_library(bake_render
SOURCES bake_render.cpp
LINK PUBLIC bake_plan sampler_core)
LINK PUBLIC bake_plan sampler_core limiter)
reasampler_test(bake_render LINK bake_render)
# No library of its own: the derived window is a PROPERTY of bake_plan + bake_render
@@ -17,4 +19,6 @@ reasampler_test(bake_window LINK bake_plan bake_render)
# sample_map carries InstrumentParams, which is the whole of what a reset rewrites.
reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map)
reasampler_test(bake_reset LINK bake_reset)
# loop_marks is a TEST-only edge: it defines what a neutral loop looks like on the band, so
# the reset's loop assertions read it rather than restating it.
reasampler_test(bake_reset LINK bake_reset loop_marks)
+31 -17
View File
@@ -6,6 +6,7 @@
#include <cmath>
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold)
#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound)
#include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
@@ -28,22 +29,36 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
return true;
}
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
// stretches how long the source takes to play out. Preserve decouples the two, and a Gate
// release is ticked per output frame, so neither is affected.
double downwardSemitones(const PlayParams& play, int velocity) {
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
//
// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
// feeds the stretcher at it, so either way the source is consumed at that many frames per
// output frame. Taken through the engine's clamp, because that is the value Voice::start
// actually plays.
//
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
// rate alone, which is the only sense in which the two are decoupled there.
//
// A Gate release is ticked per output frame, so neither term touches it.
double playbackStretch(const PlayParams& play, int velocity) {
double down = 0.0;
if (play.pitchEngine == PitchEngine::Varispeed) {
down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
// Taken as a bound rather than exactly, like the velocity term beside it: an upward
// offset only makes the read faster, and every term in this sum is a floor.
down += (std::min)(0.0, play.pitchOffsetSemitones);
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
}
return down;
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
}
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
@@ -78,8 +93,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
const double rate = static_cast<double>(renderSampleRate);
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = effectiveStart(dialed);
const double stretch =
std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
const double stretch = playbackStretch(dialed.play, p.velocity.value());
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0;
+3 -2
View File
@@ -32,7 +32,8 @@ bool bakeWindowNeedsHold(const SampleData& dialed);
// the bake renders at, which is what the engine's frame counts are consumed against):
//
// Trigger — the note IS the play span (note-off is ignored anyway), stretched by the
// deepest downward Varispeed offset.
// slowest read the dialed voice can reach: Rate under BOTH engines, plus the
// deepest downward pitch offset under Varispeed.
// Gate, loop — `hold` is the note length; the end offset is the release.
// Gate, no loop— the read head runs off the source and frees the voice whatever the gate is
// doing, so the note is the whole post-start span, stretched the same way.
@@ -44,7 +45,7 @@ bool bakeWindowNeedsHold(const SampleData& dialed);
// Every case is padded by the voice's terminal declick ramp (kDeclickFrames): trailing
// silence is free, and closing the window on the frame the ramp starts is a hard cut.
// `hold` is read only in the Gate-with-loop case; `velocity` is the velocity the note fires
// at, and it feeds the Varispeed stretch as well as the render.
// at, and it feeds the Varispeed half of that stretch as well as the render.
//
// Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` —
// meets the tempo in resolveNote, with the rest of the program's beat-denominated fields.
+40 -12
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include "core/instrument/engine/limiter.h"
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::instrument::bake {
@@ -18,7 +19,8 @@ constexpr std::int64_t kBlockFrames = 512;
} // namespace
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
@@ -36,9 +38,16 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
sample.live = nullptr;
const int channels = sample.channelCount();
// The limiter delays its output by its lookahead, so the buffers carry that many extra
// frames and the window is read that far in — the file is the same frames it would be
// with the limiter bypassed, not the capture shifted late by 2 ms. The extra input is
// SILENCE rather than more rendered audio: the file ends at the window, so a peak past
// it is not in the capture and must not duck the frames that are.
const auto flushFrames = static_cast<std::size_t>(
limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0);
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
std::vector<AudioSample> left(rendered, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
std::vector<AudioSample> left(rendered + flushFrames, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered + flushFrames : 0u, 0.f);
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
@@ -69,20 +78,39 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
pos += chunk;
}
// The whole master stage is printed here rather than left for the processor, in the
// processor's own order — gain, then the limiter — because resetAfterBake hands both
// controls back neutral: a render that only summed voices would return every iteration
// shifted by 1/gain and unlimited, and a gain dialed to silence would come back at full
// level. A flat gain multiply, not the processor's per-sample ramp: the gain is constant
// for the whole render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (AudioSample& s : left) s *= gain;
for (AudioSample& s : right) s *= gain;
if (limiterEnabled) {
engine::Limiter limiter;
// Enabled BEFORE prepare, whose reset snaps to the enable target: that starts the
// render already engaged. Enabling afterwards takes process()'s live-engage path,
// which mutes for the delay-line prime and then fades in — silencing the head of the
// capture. prepare()'s allocation and transcendentals are legal here: the bake runs
// on the UI thread, never in process().
limiter.setEnabled(true);
limiter.prepare(plan.sampleRate);
// One call: kMaxBakeFrames bounds the whole buffer well inside int, and a block
// split would change nothing (the limiter carries its state across calls).
limiter.process(left.data(), channels == 2 ? right.data() : nullptr,
static_cast<int>(left.size()));
}
out.channelCount = channels;
out.sampleRate = plan.sampleRate;
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
const auto lead = static_cast<std::size_t>(plan.leadInFrames) + flushFrames;
const auto total = static_cast<std::size_t>(plan.totalFrames);
out.interleaved.resize(total * static_cast<std::size_t>(channels));
// Printed here rather than left for the processor: resetAfterBake hands master gain
// back to unity, so a render that only summed voices would return every iteration
// shifted by 1/gain, and a gain dialed to silence would come back at full level. A flat
// multiply, not the processor's per-sample ramp: the gain is constant for the whole
// render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (std::size_t f = 0; f < total; ++f) {
out.interleaved[f * channels] = left[lead + f] * gain;
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
out.interleaved[f * channels] = left[lead + f];
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f];
}
return out;
}
+7 -6
View File
@@ -29,11 +29,12 @@ struct BakeAudio {
bool empty() const { return frameCount() == 0; }
};
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
// post-mixer gain the processor applies after the engine; see the gain multiply in
// bake_render.cpp for why it is printed here rather than left to the processor. The result
// is the plan's captured window: the lead-in frames are rendered and dropped. An unplayable
// sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
// Renders `plan` through `sample`'s own voice path and then the master stage the processor
// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see
// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled`
// false yields the pre-limiter render. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled);
} // namespace reasampler::instrument::bake
+2 -1
View File
@@ -12,7 +12,8 @@ namespace reasampler::instrument::bake {
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
// parameter set; it is answered here because renderBake prints it into the file (see
// bake_render.cpp's gain multiply) rather than left to the shell.
// bake_render.cpp's master stage) rather than left to the shell. The limiter needs no field
// of its own: its enable rides the parameter set, and the render prints it too.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
+46 -1
View File
@@ -5,6 +5,12 @@ reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
reasampler_test(pitch_shift LINK pitch_shift)
# Deliberately NOT linked by sampler_core, enforcing period_detect.h's off-audio-thread
# invariant at build time: sampler_core_tests links sampler_core and nothing else, so no TU
# on the render path can name detectPeriod without failing to link.
reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks)
reasampler_test(period_detect LINK period_detect)
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
# depend on the curve without inheriting the editor's layout types.
@@ -32,7 +38,8 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span)
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span
time_stretch)
# Links only sampler_core: linking more would break the plain-data-boundary proof a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
@@ -48,3 +55,41 @@ reasampler_test(live_delivery LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
# Measurement harness for Preserve on low-frequency material: how the splice search's
# reachable relocation interval interacts with a long source period. Written longhand and
# deliberately NOT add_test()'d it sweeps frequencies, windows and spectra and takes ~2m40s
# in Debug, which does not belong in a gate whose other targets run in seconds. It still
# builds with everything else, so it cannot rot into non-compilation. Run it by hand, in
# Release, when the question is what Preserve does to a given frequency.
add_executable(preserve_low_frequency_tests
${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp)
# period_detect beside sampler_core, not through it: the harness plays the role the loader
# does, which is exactly the seam under measurement.
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect)
# Bridges the two structural proofs above (sample_map never links the voice engine;
# sampler_core never links period_detect) for the one case that needs both: a REAL detected
# period reaching a real Preserve render. Its own target rather than extending either.
reasampler_test(period_render_integration LINK sample_map sampler_core)
# The Preserve read's source-feed schedule the TIME half beside pitch_shift's PITCH half.
# Header-only (it sits on the per-sample feed), hence INTERFACE.
add_library(time_stretch INTERFACE)
target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR})
target_link_libraries(time_stretch INTERFACE loop_span)
reasampler_test(time_stretch LINK time_stretch)
# The master bus's two pure halves. Neither links the engine: the limiter runs on the summed
# output, and the ballistics run on what the audio thread published about it.
reasampler_pure_library(limiter SOURCES limiter.cpp)
reasampler_test(limiter LINK limiter)
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
reasampler_test(meter_ballistics LINK meter_ballistics)
# The meter's ACCUMULATE half, beside the ballistics that consume it. Header-only (the folds
# sit on the audio thread's per-block path), hence INTERFACE.
add_library(meter_accumulate INTERFACE)
target_include_directories(meter_accumulate INTERFACE ${REASAMPLER_SRC_DIR})
reasampler_test(meter_accumulate LINK meter_accumulate)
+10 -3
View File
@@ -421,7 +421,12 @@ public:
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
void snapLive(const PitchEnvParams& params) {
//
// Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is
// an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a
// live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact.
void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) {
span_ = spanFrames > 0 ? spanFrames : 0;
params_.peakSemitones = params.peakSemitones;
params_.shape = params.shape;
fit_ = fitAhd(span_, params_.shape);
@@ -430,9 +435,11 @@ public:
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
// level, not a duration).
void applyLive(const PitchEnvParams& params) {
// level, not a duration). A moved span re-fits under the same rule, so a live Pitch move
// reshapes this envelope continuously instead of leaving it on the note-on read rate.
void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) {
const double before = offsetAt();
span_ = spanFrames > 0 ? spanFrames : 0;
const AhdSpan next = fitAhd(span_, params.shape);
pos_ = holdPhase(fit_, next);
params_.peakSemitones = params.peakSemitones;
@@ -1,10 +1,16 @@
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
# The frozen control-position laws are their own target: they are the filter's PARAMETER
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
# that consumer does not take a link edge onto the per-voice kernel the extension's link
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
reasampler_pure_library(filter SOURCES
filter_params.cpp
filter_coeffs.cpp
filter_morph.cpp
voice_filter.cpp)
voice_filter.cpp
LINK PUBLIC filter_params)
# Four test targets along the module's own seams so each asserts one domain. filter_tests
# alone owns the analytic reference and the steady-state gain measurement a forked copy of
@@ -52,6 +52,13 @@ float filterDriveDepthFromNorm(float norm) {
return static_cast<float>(kFilterDriveDepthMax * n * n);
}
float filterNormFromDriveDepth(float depth) {
if (!(depth > 0.0f)) return 0.0f; // also catches NaN
if (depth >= kFilterDriveDepthMax) return 1.0f;
return static_cast<float>(
std::sqrt(static_cast<double>(depth) / static_cast<double>(kFilterDriveDepthMax)));
}
float filterNormFromQ(float q) {
if (!(q > kFilterQMin)) return 0.0f;
if (q >= kFilterQMax) return 1.0f;
@@ -49,4 +49,9 @@ float filterNormFromQ(float q);
// linear rather than merely close.
float filterDriveDepthFromNorm(float norm);
// Exact inverse of filterDriveDepthFromNorm; out-of-range depth clamps to 0 or 1. The analytic
// inverse of a frozen law is not a change to it — it has the standing the two inverses above
// already have.
float filterNormFromDriveDepth(float depth);
} // namespace reasampler::instrument::engine::filter
+227
View File
@@ -0,0 +1,227 @@
// limiter.cpp — see limiter.h.
#include "core/instrument/engine/limiter.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::engine {
namespace {
constexpr int kProtoLen = kLimiterOversample * kLimiterOsTaps + 1; // 33: odd, so phase 0 is exact
double sincPi(double x) {
if (x == 0.0) return 1.0;
const double a = 3.14159265358979323846 * x;
return std::sin(a) / a;
}
} // namespace
double limiterCeilingLinear() { return std::pow(10.0, kLimiterCeilingDbTp / 20.0); }
int limiterLookaheadSamples(double sampleRate) {
if (!(sampleRate > 0.0)) return 0;
const int n = static_cast<int>(kLimiterLookaheadSeconds * sampleRate + 0.5);
// One sample above the detector's group delay is the floor: the smoothing window must have
// at least one entry of its own for the no-overshoot bound to say anything.
return n > kLimiterOsDelay ? n : kLimiterOsDelay + 1;
}
void Limiter::prepare(double sampleRate) {
latency_ = limiterLookaheadSamples(sampleRate);
if (latency_ <= 0) latency_ = kLimiterOsDelay + 1;
window_ = latency_ - kLimiterOsDelay + 1;
ceiling_ = static_cast<float>(limiterCeilingLinear());
const double rate = sampleRate > 0.0 ? sampleRate : 48000.0;
releaseCoeff_ = static_cast<float>(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate)));
switchStep_ = static_cast<float>(1.0 / (kLimiterMuteSeconds * rate));
// Windowed-sinc polyphase interpolator, built here because it costs transcendentals.
// Phase 0's taps all land on sinc zeros except the centre, so it is an exact delay and is
// read straight out of the history instead of being convolved.
for (int p = 0; p < kLimiterOversample; ++p) {
for (int k = 0; k < kLimiterOsTaps; ++k) {
const int i = kLimiterOversample * k + p;
const double centred = static_cast<double>(i) - (kProtoLen - 1) / 2.0;
const double hann =
0.5 - 0.5 * std::cos(2.0 * 3.14159265358979323846 * i / (kProtoLen - 1));
osTaps_[p][k] = static_cast<float>(sincPi(centred / kLimiterOversample) * hann);
}
}
delayL_.assign(static_cast<std::size_t>(latency_), 0.f);
delayR_.assign(static_cast<std::size_t>(latency_), 0.f);
wedgeVal_.assign(static_cast<std::size_t>(window_), 1.f);
wedgeIdx_.assign(static_cast<std::size_t>(window_), 0);
avgRing_.assign(static_cast<std::size_t>(window_), 1.f);
reset();
}
void Limiter::clearState() {
std::fill(delayL_.begin(), delayL_.end(), 0.f);
std::fill(delayR_.begin(), delayR_.end(), 0.f);
delayPos_ = 0;
for (int i = 0; i < kLimiterOsTaps; ++i) { histL_[i] = 0.f; histR_[i] = 0.f; }
histPos_ = 0;
wedgeHead_ = 0;
wedgeCount_ = 0;
pushIndex_ = 0;
std::fill(avgRing_.begin(), avgRing_.end(), 1.f);
avgSum_ = static_cast<double>(window_);
avgPos_ = 0;
releaseGain_ = 1.f;
}
void Limiter::reset() {
clearState();
active_ = target_.load(std::memory_order_relaxed);
switchGain_ = active_ ? 1.f : 0.f;
primeRemaining_ = 0;
}
void Limiter::setEnabled(bool on) { target_.store(on, std::memory_order_relaxed); }
float Limiter::detectTruePeak(float xl, float xr, bool stereo) {
histPos_ = (histPos_ + 1) & (kLimiterOsTaps - 1);
histL_[histPos_] = xl;
if (stereo) histR_[histPos_] = xr;
// Phase 0 is the exact delay, so the sample under test is read, not convolved.
const int base = (histPos_ - kLimiterOsDelay + kLimiterOsTaps) & (kLimiterOsTaps - 1);
float peak = std::fabs(histL_[base]);
if (stereo) {
const float r0 = std::fabs(histR_[base]);
if (r0 > peak) peak = r0;
}
for (int p = 1; p < kLimiterOversample; ++p) {
float accL = 0.f, accR = 0.f;
for (int k = 0; k < kLimiterOsTaps; ++k) {
const int idx = (histPos_ - k + kLimiterOsTaps) & (kLimiterOsTaps - 1);
accL += osTaps_[p][k] * histL_[idx];
if (stereo) accR += osTaps_[p][k] * histR_[idx];
}
const float al = std::fabs(accL);
if (al > peak) peak = al;
if (stereo) {
const float ar = std::fabs(accR);
if (ar > peak) peak = ar;
}
}
return peak;
}
float Limiter::smoothGain(float target) {
// Sliding minimum over `window_` via a monotonic wedge. Expiring the front BEFORE the push
// is what bounds the wedge to `window_` entries — pushing first can lap the ring. Wraps by
// compare-and-subtract, matching delayPos_/avgPos_: window_ is not a power of two, so `%`
// would not strength-reduce on this per-sample path.
while (wedgeCount_ > 0 &&
wedgeIdx_[static_cast<std::size_t>(wedgeHead_)] <= pushIndex_ - window_) {
wedgeHead_ = (wedgeHead_ + 1 == window_) ? 0 : wedgeHead_ + 1;
--wedgeCount_;
}
while (wedgeCount_ > 0) {
const int backSum = wedgeHead_ + wedgeCount_ - 1;
const int back = (backSum >= window_) ? backSum - window_ : backSum;
if (wedgeVal_[static_cast<std::size_t>(back)] < target) break;
--wedgeCount_;
}
const int slotSum = wedgeHead_ + wedgeCount_;
const int slot = (slotSum >= window_) ? slotSum - window_ : slotSum;
wedgeVal_[static_cast<std::size_t>(slot)] = target;
wedgeIdx_[static_cast<std::size_t>(slot)] = pushIndex_;
++wedgeCount_;
++pushIndex_;
const float windowMin = wedgeVal_[static_cast<std::size_t>(wedgeHead_)];
// Moving average of the same width over those minima.
avgSum_ += static_cast<double>(windowMin) - static_cast<double>(avgRing_[static_cast<std::size_t>(avgPos_)]);
avgRing_[static_cast<std::size_t>(avgPos_)] = windowMin;
avgPos_ = (avgPos_ + 1 == window_) ? 0 : avgPos_ + 1;
float smoothed = static_cast<float>(avgSum_ / window_);
// Never above unity — the structural form of "no makeup gain, ever", and what makes the
// at-rest gain land on EXACTLY 1.0f after the running sum has been added to and subtracted
// from for hours.
if (!(smoothed < 1.f)) smoothed = 1.f;
// Release: falls with the smoother, rises no faster than the one-pole. Staying at or below
// `smoothed` is what preserves the no-overshoot bound.
if (smoothed < releaseGain_) releaseGain_ = smoothed;
else releaseGain_ += (smoothed - releaseGain_) * releaseCoeff_;
return releaseGain_;
}
float Limiter::process(float* left, float* right, int frames) {
if (!left || frames <= 0 || latency_ <= 0) return 1.f;
const bool want = target_.load(std::memory_order_relaxed);
if (!want && !active_) return 1.f; // settled bypass: not one sample read or written
if (want && !active_) {
// A live engage. The dry path leaves circuit AT THIS SAMPLE rather than fading out:
// fading it would emit unlimited signal at a partial weight, which is a peak over the
// ceiling. Silence covers the delay line's prime, then the fade-in rides the limited
// path, every sample of which is already under the ceiling.
clearState();
active_ = true;
switchGain_ = 0.f;
primeRemaining_ = latency_;
}
const bool stereo = (right != nullptr);
float blockMin = 1.f;
for (int i = 0; i < frames; ++i) {
const float dryL = left[i];
const float dryR = stereo ? right[i] : 0.f;
const float peak = detectTruePeak(dryL, dryR, stereo);
const float targetGain = peak > ceiling_ ? ceiling_ / peak : 1.f;
const float gain = smoothGain(targetGain);
const std::size_t slot = static_cast<std::size_t>(delayPos_);
const float wetL = delayL_[slot] * gain;
const float wetR = stereo ? delayR_[slot] * gain : 0.f;
delayL_[slot] = dryL;
if (stereo) delayR_[slot] = dryR;
delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1;
// Settled engaged is a branch rather than `wet * 1.0f` so it is bit-exact.
const float s = switchGain_;
if (s >= 1.f) {
left[i] = wetL;
if (stereo) right[i] = wetR;
} else if (s > 0.f) {
left[i] = wetL * s;
if (stereo) right[i] = wetR * s;
} else {
left[i] = 0.f;
if (stereo) right[i] = 0.f;
}
// `gain` is the limiter's own reduction, computed from the real input this sample
// whether or not the mute is currently scaling it toward silence — publishing it
// unscaled is what lets the meter show "really limiting" and not "just muting".
if (gain < blockMin) blockMin = gain;
// A disengage is tested FIRST so a toggle-off arriving mid-engage abandons the prime
// instead of waiting it out in silence.
if (!want) {
switchGain_ = s - switchStep_;
if (switchGain_ <= 0.f) {
// The disengage completes HERE, sample-accurately: the delay leaves circuit and
// the rest of the block is the dry buffer, untouched. Resuming from silence is
// the accepted discontinuity; fading the dry path back in instead would put
// unlimited signal at a partial weight, which is the leak the ceiling forbids.
switchGain_ = 0.f;
active_ = false;
break;
}
} else if (primeRemaining_ > 0) {
--primeRemaining_;
} else if (s < 1.f) {
switchGain_ = (s + switchStep_ >= 1.f) ? 1.f : s + switchStep_;
}
}
return blockMin;
}
} // namespace reasampler::instrument::engine
+129
View File
@@ -0,0 +1,129 @@
// limiter.h — the master bus's lookahead brickwall limiter: true-peak sidechain detection,
// stereo-linked gain, and NO makeup gain of any kind. RT: process() allocates nothing, takes
// no lock and evaluates no transcendental; prepare() owns every allocation and every exp/pow.
// Bypassed and settled, process() returns without touching a sample — that untouched buffer
// is what makes the master bus byte-identical to the bare ramped multiply with the limiter off.
#pragma once
#include <atomic>
#include <cstdint>
#include <vector>
namespace reasampler::instrument::engine {
// The BAKED ceiling. A safety device with no configurable controls, so this is not a
// parameter. dBTP is a TRUE-peak target, which is why the detector oversamples and the
// signal path never does — though the bound is on the detector's 4x-oversampled ESTIMATE,
// not infinite-resolution true peak (normal for any practical TP limiter, and part of why
// this ceiling sits at -0.3 rather than 0).
inline constexpr double kLimiterCeilingDbTp = -0.3;
// The total delay the limiter imposes while engaged, and therefore the plugin's whole reported
// PDC latency. The detector's own group delay is inside this budget, not on top of it.
inline constexpr double kLimiterLookaheadSeconds = 0.002;
// Gain recovery. The min-then-average smoother releases in one lookahead window on its own,
// which distorts low frequencies; this one-pole only ever slows the RISE, so the smoother's
// no-overshoot bound survives it unchanged.
inline constexpr double kLimiterReleaseSeconds = 0.100;
// The transition mute. Long enough that the fade is not itself an edge and that it dwarfs the
// 2 ms delay-line prime it covers; short enough that the whole muted window (prime + fade) is
// ~12 ms rather than a gap. Linear in amplitude, not equal-power: this fades ONE leg to
// silence, it does not cross two.
inline constexpr double kLimiterMuteSeconds = 0.010;
// 4x true-peak oversampling (ITU-R BS.1770's floor at 48 kHz) over an 8-tap-per-phase
// polyphase interpolator. The 33-tap prototype's centre tap makes phase 0 an exact 4-sample
// delay, and that delay is the detector's group delay.
inline constexpr int kLimiterOversample = 4;
inline constexpr int kLimiterOsTaps = 8;
inline constexpr int kLimiterOsDelay = 4;
// kLimiterCeilingDbTp as a linear magnitude.
double limiterCeilingLinear();
// The delay the limiter imposes while engaged, in samples at `sampleRate` — what the plugin
// reports to the host's PDC. 0 at a non-positive rate; never below the detector's own delay.
int limiterLookaheadSamples(double sampleRate);
// The master-bus limiter. One instance per plugin instance; prepare() before the first block.
//
// The gain law is a sliding MINIMUM of the per-sample target gain over the lookahead window,
// then a MOVING AVERAGE of the same width. Every term of that average is a minimum whose own
// window contains the sample being gained, so the smoothed gain is <= the target gain at every
// sample by construction — the ceiling is held structurally rather than by a tuned attack.
//
// SWITCHING IS A MUTE, NOT A BLEND. Unlimited signal is emitted at weight 1 (settled bypass,
// which is the untouched buffer) or at weight 0, never in between — a fraction of an unlimited
// signal is a peak above the ceiling, which is exactly the leak this design forbids. So the
// FADE always rides the limited path (any weight of it is already under the ceiling, since the
// mute only scales down) and the HARD EDGE always lands on the bypassed side, against silence:
// engaging mutes at once, holds while the delay line primes, then fades the limited path in;
// disengaging fades the limited path out and resumes the dry buffer from silence. That
// discontinuity is accepted; a spike is not.
class Limiter {
public:
// Sizes the delay line, the detector and the smoothers, and snaps to the current enable
// state. Allocates and evaluates transcendentals: main/UI thread only, never in process().
void prepare(double sampleRate);
// Clears the delay line and the detector and snaps to the current enable state, skipping
// the transition mute — an activation has nothing sounding to be continuous with.
// Main/UI thread only (the host guarantees process() is stopped at both call sites).
void reset();
// The enable target. Set on the UI thread, observed by process() at block start.
void setEnabled(bool on);
bool enabled() const { return target_.load(std::memory_order_relaxed); }
// Applies the limiter in place over `frames` of `left` (and `right`, which may be null for
// a mono buffer). Returns the SMALLEST gain the LIMITER ITSELF computed this block —
// smoothGain's output against the real input, at every sample including a muted one — NOT
// scaled by the transition mute. The mute is a switch, not limiting: scaling by it would
// report 0.0 (full reduction) on every toggle regardless of program content, which is a
// meter defect, not a fact about the bus. 1.0 means no detected peak exceeded the ceiling,
// whether settled bypassed or mid-mute over quiet material.
float process(float* left, float* right, int frames);
private:
void clearState();
// The detector's true-peak estimate for the sample kLimiterOsDelay back, given the newest
// input frame. Advances the FIR history.
float detectTruePeak(float xl, float xr, bool stereo);
// Pushes one target gain through the sliding minimum and the moving average.
float smoothGain(float target);
std::atomic<bool> target_{false};
// --- prepared geometry ---
int latency_ = 0; // total delay; also the delay ring's length
int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1
float ceiling_ = 1.f;
float releaseCoeff_ = 1.f;
float switchStep_ = 1.f;
float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay)
// --- audio-thread state ---
std::vector<float> delayL_, delayR_;
int delayPos_ = 0;
float histL_[kLimiterOsTaps] = {};
float histR_[kLimiterOsTaps] = {};
int histPos_ = 0;
// Monotonic wedge over the target gain: values ascending from the front, so the front is
// the window minimum. Amortized O(1) per sample, bounded by 2 ops per push over a block.
std::vector<float> wedgeVal_;
std::vector<std::int64_t> wedgeIdx_;
int wedgeHead_ = 0, wedgeCount_ = 0;
std::int64_t pushIndex_ = 0;
std::vector<float> avgRing_;
double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever
int avgPos_ = 0;
float releaseGain_ = 1.f;
bool active_ = false; // the limited path is in circuit (engaged, or still fading out)
float switchGain_ = 0.f; // the transition mute; only ever scales the LIMITED path
int primeRemaining_ = 0; // samples held at silence while the delay line fills
};
} // namespace reasampler::instrument::engine
+55 -2
View File
@@ -5,8 +5,17 @@
namespace reasampler::instrument::engine {
LiveValues foldLive(const PlayParams& params) {
LiveValues v;
LiveValues foldLive(const PlayParams& params, double keyTrack) {
// Value-initialized, so the padding is determinate too. Nothing reads it — the block's
// equality is field-wise for exactly that reason — but this is the one construction site
// every publisher goes through, and an object with indeterminate bytes travelling under a
// seqlock is a hazard worth not having. Off the audio thread; the memset costs nothing here.
LiveValues v{};
v.keyTrack = keyTrack;
// Folded here, not at the voice: Voice::start reads the block's value directly, so the
// spline rule has to be applied on the way in or the two would answer differently.
v.splineActive = splineActive(params);
v.lengthFraction = effectiveLengthFraction(params);
v.filterSettings = params.filter.settings;
v.filterModAmount = params.filter.modAmount;
v.filterVelAmount = params.filter.velAmount;
@@ -16,9 +25,53 @@ LiveValues foldLive(const PlayParams& params) {
v.adsr = params.adsr;
v.ampAhd = params.trigAhd;
v.pitchEnv = params.pitchEnv;
v.playRate = params.playRate;
v.pitchOffsetSemitones = params.pitchOffsetSemitones;
return v;
}
namespace {
bool sameAdsr(const AdsrParams& a, const AdsrParams& b) {
return a.attackFrames == b.attackFrames && a.holdFrames == b.holdFrames &&
a.decayFrames == b.decayFrames && a.sustainLevel == b.sustainLevel &&
a.releaseFrames == b.releaseFrames && a.attackCurve == b.attackCurve &&
a.decayCurve == b.decayCurve && a.releaseCurve == b.releaseCurve;
}
bool sameAhd(const AhdParams& a, const AhdParams& b) {
return a.attackFrames == b.attackFrames && a.decayFrames == b.decayFrames &&
a.holdFraction == b.holdFraction && a.attackCurve == b.attackCurve &&
a.decayCurve == b.decayCurve;
}
bool sameFilterSettings(const filter::FilterSettings& a, const filter::FilterSettings& b) {
return a.cutoffNorm == b.cutoffNorm && a.resonanceNorm == b.resonanceNorm &&
a.morphNorm == b.morphNorm && a.driveNorm == b.driveNorm &&
a.morphLaw == b.morphLaw;
}
} // namespace
bool operator==(const LiveValues& a, const LiveValues& b) {
return sameFilterSettings(a.filterSettings, b.filterSettings) &&
a.filterModAmount == b.filterModAmount &&
a.filterVelAmount == b.filterVelAmount &&
a.filterKeyTrack == b.filterKeyTrack &&
sameAdsr(a.filterEnv, b.filterEnv) &&
sameAhd(a.filterAhd, b.filterAhd) &&
sameAdsr(a.adsr, b.adsr) &&
sameAhd(a.ampAhd, b.ampAhd) &&
a.pitchEnv.enabled == b.pitchEnv.enabled &&
a.pitchEnv.peakSemitones == b.pitchEnv.peakSemitones &&
sameAhd(a.pitchEnv.shape, b.pitchEnv.shape) &&
a.playRate == b.playRate &&
a.pitchOffsetSemitones == b.pitchOffsetSemitones &&
a.keyTrack == b.keyTrack &&
a.lengthFraction == b.lengthFraction &&
a.splineActive == b.splineActive;
}
double liveRampStep(double sampleRate) {
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
return 1.0 / (kLiveRampSeconds * sampleRate);
+48 -2
View File
@@ -44,15 +44,57 @@ struct LiveValues {
AdsrParams adsr{};
AhdParams ampAhd{};
PitchEnvParams pitchEnv{};
// The block's THIRD commit class, and the reason this comment is here rather than at the
// predicate: playRate is published like any live control but read ONLY at note-on, by
// Voice::start via VoiceEngine::startVoice — never by applyLive on a sounding voice. A live
// rate would mean re-folding an already-resolved sustain loop and re-mapping a contour
// mid-note, both of which are note-on folds. pitchOffsetSemitones has no such tie and is
// ordinarily live.
double playRate = 1.0;
double pitchOffsetSemitones = 0.0;
// Two more members of playRate's note-on-latched class, here for the same reason it is:
// both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live
// delivery would retune or re-span a note already struck. Voice::start receives them as
// arguments; applyLive never touches either.
double keyTrack = kKeyTrackDefault;
// ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function
// over the whole sample, so the stored knob is inert while one is active and the block must
// carry what the voice will actually play, not the stored value.
double lengthFraction = 1.0;
// The drawn-EG state the fold above reads. A mode flip travels by reload like the contours
// themselves, so this is not a control; it rides here only so a block-boundary write of
// Trigger length (a host automation point) can apply the SAME fold rather than un-doing it.
bool splineActive = false;
};
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
static_assert(std::is_trivially_copyable_v<LiveValues>,
"the live block is copied under a seqlock — it must stay a plain value");
// A SIZE-CHANGING edit only: padding can absorb a member added beside an existing one (a bool
// beside splineActive, a fifth FilterSettings float) without moving this literal at all, so this
// assert is NOT the guard against a forgotten operator== field —
// testEveryFieldOfLiveValuesIsCompared (test_live_params.cpp) is that guard, poisoning one leaf
// at a time. This assert only catches an edit that changes sizeof(LiveValues) itself. Confirmed
// 352 bytes, MSVC 19.44 x64, Release (`SizeProbe<sizeof(LiveValues)>`, an incomplete-template
// size probe whose error message reports the value). Bump the literal AND operator== together.
static_assert(sizeof(LiveValues) == 352,
"a member was added or removed — extend operator== in live_params.cpp to match");
// FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries
// padding, and nothing gives that padding a determinate value across a copy: NRVO is optional
// and the implicit copy/move is specified member-wise, so two blocks folded from the same
// parameter set are NOT reliably byte-equal. A byte compare therefore reports differences that
// do not exist — which is exactly what it did before this existed. Listed member by member, so a
// member added to the block above must be added here as well; this sits directly beneath the
// struct for that reason.
bool operator==(const LiveValues& a, const LiveValues& b);
inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a == b); }
// The ONE derivation of the live block from the parameter set. Every publisher goes through
// here so there is a single site to keep in step with PlayParams.
LiveValues foldLive(const PlayParams& params);
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
// because it belongs to the capture/instrument scalar beside the play bundle, not to
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
// and an even generation; the reader copies the block and re-checks the generation, retrying
@@ -88,6 +130,10 @@ public:
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
+7 -12
View File
@@ -6,7 +6,6 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
namespace reasampler::instrument::engine {
@@ -18,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
double masterGainDbFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
// UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's
// reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a
// hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the
// half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0.
// PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15.
// Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables
// contraction here breaks the exactness test in test_param_units, which is where it surfaces.
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
}
@@ -37,15 +43,4 @@ double masterGainNormFromLinear(double linear) {
return masterGainNormFromDb(20.0 * std::log10(linear));
}
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
if (!buf || len == 0) return;
norm = clamp01(norm);
if (norm <= 0.0) {
std::snprintf(buf, len, "-inf");
return;
}
const double db = masterGainDbFromNorm(norm);
std::snprintf(buf, len, "%+.1fdB", db);
}
} // namespace reasampler::instrument::engine
-6
View File
@@ -7,8 +7,6 @@
#pragma once
#include <cstddef>
namespace reasampler::instrument::engine {
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
@@ -31,8 +29,4 @@ double masterGainLinearFromNorm(double norm);
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
double masterGainNormFromLinear(double linear);
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// Writes at most `len` bytes including the terminator.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine
@@ -0,0 +1,69 @@
// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold
// into the two windows the UI drains, and the drain that starts the next window. The ballistics
// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio
// thread's per-block path. The folds are templated on the accumulator ONLY so the
// drain-inside-the-fold interleave below can be pinned deterministically instead of raced for.
#pragma once
#include <atomic>
namespace reasampler::instrument::engine {
// A lock-backed std::atomic<float> would put a mutex on the audio thread; assert the freedom
// rather than assume it.
static_assert(std::atomic<float>::is_always_lock_free,
"the meter folds run on the audio thread and must be lock-free");
// The two windows' identity elements: a peak window that has seen nothing reports silence, a
// gain window that has seen nothing reports no reduction. They are what a consume reinstalls,
// so they live beside the folds rather than at the reader.
inline constexpr float kMeterPeakIdentity = 0.f;
inline constexpr float kMeterGainIdentity = 1.f;
// Folds one block's reading into its accumulator — a running max for a peak, a running min for
// the limiter's gain — so the ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512
// all reach the meter instead of the one it happened to sample.
//
// An UNCONDITIONAL read-modify-write, and that is the whole point. The UI's consume is an
// exchange that can land between a plain load and its store, and a load-compare-store fold
// would then drop the block outright: it decided against storing by comparing with a window the
// UI has since taken, so that block's reading enters neither the old window nor the new one.
// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on
// exit however the two interleave. STRONG, so the loop is bounded by the interference it is
// written against: the audio thread is the only writer besides the UI's single consume, and
// weak's permitted spurious failure would make an unbounded retry count reachable with no
// interference at all. Three calls per block, so the strong form costs nothing measurable.
// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is
// templated only so a test can pin the interleave; it must behave as std::atomic<float>.
template <class Accumulator>
inline void foldPeak(Accumulator& acc, float blockPeak) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
template <class Accumulator>
inline void foldMinGain(Accumulator& acc, float blockMinGain) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
// Takes what the window accumulated and reinstalls the identity element, which IS what starts
// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who).
// Concrete: only the folds have the interleave a test seam buys, and a template over one
// instantiation models nothing.
inline float consumePeak(std::atomic<float>& acc) {
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
}
inline float consumeMinGain(std::atomic<float>& acc) {
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,57 @@
// meter_ballistics.cpp — see meter_ballistics.h.
#include "core/instrument/engine/meter_ballistics.h"
#include <cmath>
namespace reasampler::instrument::engine {
double meterDbFromLinear(double linear) {
if (!(linear > 0.0)) return kMeterFloorDb; // also catches NaN
const double db = 20.0 * std::log10(linear);
return db < kMeterFloorDb ? kMeterFloorDb : db;
}
double meterNormFromDb(double db) {
if (!(db > kMeterFloorDb)) return 0.0; // also catches NaN
if (db >= kMeterTopDb) return 1.0;
return (db - kMeterFloorDb) / (kMeterTopDb - kMeterFloorDb);
}
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds) {
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double fall = kMeterFallDbPerSecond * dt;
const double peakDb = meterDbFromLinear(blockPeakLinear);
MeterState next = prev;
// Instantaneous rise, timed fall — one expression, because a fall can never take the bar
// below the peak this very block carried.
const double fallen = prev.levelDb - fall;
next.levelDb = fallen > peakDb ? fallen : peakDb;
if (next.levelDb >= next.holdDb) {
next.holdDb = next.levelDb;
next.holdRemainingSeconds = kMeterPeakHoldSeconds;
} else {
next.holdRemainingSeconds = prev.holdRemainingSeconds - dt;
if (next.holdRemainingSeconds < 0.0) {
// Spend the overshoot as fall time so the tick's release does not quantize to the
// UI frame it happened to expire on.
const double held = kMeterFallDbPerSecond * -next.holdRemainingSeconds;
const double dropped = next.holdDb - held;
next.holdDb = dropped > next.levelDb ? dropped : next.levelDb;
next.holdRemainingSeconds = 0.0;
}
}
if (blockPeakLinear >= 1.0) next.clip = true;
return next;
}
MeterState clearMeterClip(MeterState prev) {
MeterState next = prev;
next.clip = false;
return next;
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,40 @@
// meter_ballistics.h — the output meter's ballistics and its dB scale: peak fall, peak hold,
// clip latch, and the dB -> normalized map the bar draws against. UI-thread math ONLY: the
// audio thread publishes raw block peaks per block and converts, holds and decays nothing.
#pragma once
namespace reasampler::instrument::engine {
// The scale is LINEAR IN dB across this span. Above 0 dBFS is shown because that is exactly
// what the limiter-off case has to make visible.
inline constexpr double kMeterFloorDb = -60.0;
inline constexpr double kMeterTopDb = 6.0;
// A peak meter must not smooth its attack or it under-reports, so the rise is instantaneous
// and only the fall is timed. 20 dB/s is close to the IEC 60268-18 PPM fallback.
inline constexpr double kMeterFallDbPerSecond = 20.0;
inline constexpr double kMeterPeakHoldSeconds = 1.5;
// Linear magnitude -> dBFS, floored at kMeterFloorDb — a silent block reads the floor rather
// than -inf, so the state stays a finite number the ballistics can subtract from.
double meterDbFromLinear(double linear);
// dBFS -> [0,1] up the meter, clamped at both ends.
double meterNormFromDb(double db);
struct MeterState {
double levelDb = kMeterFloorDb;
double holdDb = kMeterFloorDb;
double holdRemainingSeconds = 0.0;
bool clip = false; // latched; only clearMeterClip lowers it
};
// One UI frame of ballistics against the block peak the audio thread published and the time
// since the previous frame. Clip latches at a block peak >= 0 dBFS and is never cleared here.
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds);
// The click-to-clear on the meter's clip cap.
MeterState clearMeterClip(MeterState prev);
} // namespace reasampler::instrument::engine
@@ -0,0 +1,254 @@
// period_detect — pure implementation. See period_detect.h for the contract.
//
// YIN (de Cheveigne & Kawahara 2002), two-pass: a cumulative-mean-normalized difference
// function on a 4x box-decimated copy picks the period, then the raw difference function at
// full rate refines it to a fraction of a frame. The decimated pass is what makes the cost
// bounded; the full-rate pass is what makes the estimate precise enough to multiply — the
// splice jump is n periods, so an error of e frames lands as n*e frames of misalignment.
//
// Hand-rolled rather than autocorrelation-with-an-FFT: no third-party dependency, and the
// difference function's absolute threshold is what lets "no period here" be a real answer.
#include "core/instrument/engine/period_detect.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace reasampler::instrument::engine {
namespace {
constexpr int kDecimate = 4;
// Below this RMS a block carries no signal to find a period in; its difference function is
// numerically degenerate rather than merely inconclusive.
constexpr double kSilenceRms = 1e-5;
// Box-decimate `src[from, from+count)` by kDecimate. The averaging is the anti-alias filter:
// a plain stride would fold high partials onto the low lags the coarse pass searches.
std::vector<double> decimate(const std::vector<AudioSample>& src, std::size_t from,
std::size_t count) {
std::vector<double> out(count / kDecimate);
for (std::size_t i = 0; i < out.size(); ++i) {
double s = 0.0;
for (int k = 0; k < kDecimate; ++k) {
s += static_cast<double>(src[from + i * kDecimate + static_cast<std::size_t>(k)]);
}
out[i] = s / kDecimate;
}
return out;
}
// The cumulative-mean-normalized difference d'(tau) over lags [1, lagHi], analysis window W:
// d(tau) = sum_{j<W} (x[j] - x[j+tau])^2
// d'(tau) = d(tau) / ((1/tau) * sum_{t=1..tau} d(t))
// Index 0 is unused (set to 1.0, YIN's convention). The normalization is what makes the
// threshold below an absolute one rather than a signal-dependent one.
std::vector<double> cmndf(const std::vector<double>& x, std::size_t W, std::size_t lagHi) {
std::vector<double> dp(lagHi + 1, 1.0);
double running = 0.0;
for (std::size_t tau = 1; tau <= lagHi; ++tau) {
double d = 0.0;
for (std::size_t j = 0; j < W; ++j) {
const double diff = x[j] - x[j + tau];
d += diff * diff;
}
running += d;
dp[tau] = running > 0.0 ? d * static_cast<double>(tau) / running : 1.0;
}
return dp;
}
// Parabolic vertex through (i-1, i, i+1) as an offset in [-0.5, 0.5] from i. Zero at an end
// point or a non-minimum, which leaves the integer lag — benign, and the full-rate pass
// refines it again anyway.
double parabolicOffset(const std::vector<double>& y, std::size_t i) {
if (i == 0 || i + 1 >= y.size()) return 0.0;
const double den = y[i - 1] - 2.0 * y[i] + y[i + 1];
if (!(den > 0.0)) return 0.0; // a minimum has positive curvature
double f = 0.5 * (y[i - 1] - y[i + 1]) / den;
if (f > 0.5) f = 0.5;
if (f < -0.5) f = -0.5;
return f;
}
// YIN's absolute-threshold rule: take the FIRST dip below the threshold, walked down to its
// local bottom — not the global minimum. A periodic signal dips at every multiple of its
// period, so the global minimum is as likely to be 2P or 3P; taking the first dip is what
// makes the answer the fundamental period rather than some harmonic of it.
bool pickPeriod(const std::vector<double>& dp, std::size_t lagLo, double& tauOut,
double& dissimilarity) {
for (std::size_t tau = lagLo; tau + 1 < dp.size(); ++tau) {
if (dp[tau] >= kPeriodDetectThreshold) continue;
std::size_t t = tau;
while (t + 1 < dp.size() && dp[t + 1] < dp[t]) ++t;
tauOut = static_cast<double>(t) + parabolicOffset(dp, t);
dissimilarity = dp[t];
return true;
}
return false;
}
// The raw difference function over [lo, hi] at FULL rate, minimized parabolically. The coarse
// pass already chose which dip; this only says exactly where its bottom is. Amplitude drift
// over the few frames spanned here is negligible, so the unnormalized d() suffices.
double refineFullRate(const std::vector<AudioSample>& pcm, std::size_t from, std::size_t W,
std::size_t lo, std::size_t hi) {
std::vector<double> d(hi - lo + 1, 0.0);
for (std::size_t tau = lo; tau <= hi; ++tau) {
double s = 0.0;
for (std::size_t j = 0; j < W; ++j) {
const double diff = static_cast<double>(pcm[from + j]) -
static_cast<double>(pcm[from + j + tau]);
s += diff * diff;
}
d[tau - lo] = s;
}
const std::size_t best =
static_cast<std::size_t>(std::min_element(d.begin(), d.end()) - d.begin());
return static_cast<double>(lo + best) + parabolicOffset(d, best);
}
double blockRms(const std::vector<AudioSample>& pcm, std::size_t from, std::size_t count) {
double e = 0.0;
for (std::size_t i = 0; i < count; ++i) {
const double x = static_cast<double>(pcm[from + i]);
e += x * x;
}
return std::sqrt(e / static_cast<double>(count));
}
} // namespace
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t spanFrom, std::size_t spanCount) {
if (sampleRate <= 0 || spanCount == 0) return {};
if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {};
const double rate = static_cast<double>(sampleRate);
std::size_t lagHi = longestLagFrames(sampleRate);
const std::size_t lagLo = static_cast<std::size_t>(rate / kPeriodDetectMaxHz);
if (lagLo < 2) return {}; // a rate so low the whole search band collapses
// One probe block is W + lagHi frames with W == lagHi (YIN's usual sizing: the analysis
// window must cover the longest lag being tested). A short span shortens the search
// rather than refusing outright — a 200 ms one-shot still has a period worth finding.
if (spanCount < 2 * lagHi) lagHi = spanCount / 2;
if (lagHi <= lagLo + 2) return {};
const std::size_t block = 2 * lagHi;
// Probe POSITIONS, not disjoint blocks — see kPeriodDetectProbes in the header for why
// lagHi is the separation that makes two overlapping probes independent evidence.
const std::size_t room = spanCount - block;
const std::size_t probes = std::min<std::size_t>(kPeriodDetectProbes, 1 + room / lagHi);
// Room to spare after the last probe's block is spread between them, so the probes sample
// the whole span rather than only its opening.
const std::size_t stride = probes > 1 ? room / (probes - 1) : 0;
std::vector<double> periods;
std::vector<double> confidences;
// Probes that carried signal AND ran the real dip search — the agreement denominator. A
// silent block is no evidence either way; a block whose decimated search band or full-rate
// refine bracket collapsed to nothing (the two geometry continues below) never ran that
// search either, so it is excluded on the same footing as silence, not counted as if it had.
std::size_t evidence = 0;
for (std::size_t p = 0; p < probes; ++p) {
// (probes - 1) * stride <= room by construction, so the last block always fits.
const std::size_t from = spanFrom + p * stride;
if (blockRms(pcm, from, block) < kSilenceRms) continue;
const std::vector<double> small = decimate(pcm, from, block);
const std::size_t smallHi = lagHi / kDecimate;
const std::size_t smallW = small.size() - smallHi;
if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue; // degenerate geometry
const std::vector<double> dp = cmndf(small, smallW, smallHi);
double coarseTau = 0.0, dissimilarity = 1.0;
if (!pickPeriod(dp, std::max<std::size_t>(2, lagLo / kDecimate), coarseTau,
dissimilarity)) {
++evidence; // the search ran and found no dip: real evidence against a period
continue;
}
// Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick:
// the decimated parabola is already sub-decimated-sample accurate, so this is margin,
// not a second search.
const double centre = coarseTau * kDecimate;
const std::size_t lo = static_cast<std::size_t>(
std::max(static_cast<double>(lagLo), centre - 2.0 * kDecimate));
const std::size_t hi = static_cast<std::size_t>(
std::min(static_cast<double>(lagHi), centre + 2.0 * kDecimate));
if (hi <= lo) continue; // degenerate refine bracket
++evidence; // the search ran and found a period: real evidence for one
periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi));
confidences.push_back(1.0 - dissimilarity);
}
if (periods.empty()) return {};
// ONE piece of evidence in the whole span — either it hosted a single probe position, or
// every other probe was silent. Nothing can rule against this estimate, so the accept rests
// on pickPeriod's absolute threshold, which is a real test and not an absence of one: the
// block genuinely repeats at this lag across its whole analysis window. Refusing instead
// would deny every short one-shot a period, and a period that turns out wrong costs a
// mis-centred correlation search at the splice, not an unrefined one (pitch_shift.cpp's
// splice searches +/- maxLag around whichever jump it is handed). Do not "unify" this back
// into the majority test — at one piece of evidence that test accepts unconditionally, which
// is the same behaviour with none of the reasoning. Nor key it on how many probes SURVIVED:
// one survivor out of four that all carried signal is not this case at all.
if (evidence == 1) {
PeriodEstimate lone;
lone.frames = periods[0];
lone.confidence = confidences[0];
return lone;
}
std::vector<double> sorted = periods;
std::sort(sorted.begin(), sorted.end());
const double median = sorted[sorted.size() / 2];
// Average the probes that agree with the median rather than taking the median outright:
// averaging cancels each probe's own estimation jitter, and the jump multiplies whatever
// error survives by n.
double sum = 0.0, confSum = 0.0;
std::size_t agree = 0;
for (std::size_t i = 0; i < periods.size(); ++i) {
if (std::fabs(periods[i] - median) > kPeriodDetectAgreeTolerance * median) continue;
sum += periods[i];
confSum += confidences[i];
++agree;
}
// A STRICT MAJORITY of the probes that carried signal must agree, not merely two of them: a
// source whose first half is one period and second half another gives two probes each way,
// and taking either as "the" period would misalign every splice in the other half. Refusing
// is the right answer there — the fixed-window fallback is what a source with no ONE period
// gets. The denominator is `evidence` and not `periods.size()` because once probes overlap
// a straddling block finds no period at all rather than a third one, and counting only the
// survivors turned that two-and-two split into a two-of-three accept.
// Reached only with two or more pieces of evidence; the lone case returned above.
if (agree * 2 <= evidence) return {};
PeriodEstimate est;
est.frames = sum / static_cast<double>(agree);
est.confidence = confSum / static_cast<double>(agree);
return est;
}
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate) {
return detectPeriod(pcm, sampleRate, 0, pcm.size());
}
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate) {
const AnalysisSpan whole{0, frameCount};
if (!hasLoop || sampleRate <= 0) return whole;
if (loopStart < 0 || loopEnd <= loopStart) return whole;
if (static_cast<std::uint64_t>(loopEnd) > frameCount) return whole;
const std::size_t length = static_cast<std::size_t>(loopEnd - loopStart);
// One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the
// lowest findable fundamental — the one thing the narrower span may never cost.
const std::size_t minimum = 2 * longestLagFrames(sampleRate);
if (length < minimum) return whole;
return AnalysisSpan{static_cast<std::size_t>(loopStart), length};
}
} // namespace reasampler::instrument::engine
+122
View File
@@ -0,0 +1,122 @@
#pragma once
// period_detect — the source's own fundamental period, estimated ONCE per load from decoded
// PCM, for the Preserve splice's pitch-synchronous jump (pitch_shift.h's periodAlignedJump).
//
// Runs off the audio thread BY LINK GRAPH: sampler_core does not link this module, so no
// translation unit on the render path can name detectPeriod. A sampler's source is fixed and
// fully known at load, which is the whole reason a detector is affordable here at all.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::engine {
using audio::AudioSample;
// The period the source repeats at, in SOURCE frames, or none. Derived from the audio, never
// authored and never persisted — this is a cache, not state.
struct PeriodEstimate {
double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise)
// 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision
// is `valid()` alone and the loader takes `.frames` without reading this — its only reader is
// tests/test_period_detect.cpp. It is deliberately NOT a second accept gate: every
// accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by
// construction and any gate below that is a no-op while any gate above it is a tuned number
// with nothing to derive it from.
double confidence = 0.0;
bool valid() const { return frames > 0.0; }
};
// Fundamental bounds the search runs over. The LOW bound is the load-bearing one: a period
// only buys anything while it fits the splice's reachable jump (~1.25 windows, i.e. ~16 Hz at
// the product's 50 ms window), so searching below it would return periods the shifter must
// reject anyway. The high bound is generous — a period that short already has dozens of
// aligned landing points inside the search interval, so alignment was never in question there.
inline constexpr double kPeriodDetectMinHz = 15.0;
inline constexpr double kPeriodDetectMaxHz = 2000.0;
// YIN's absolute threshold: the first dissimilarity dip below this IS the period. A source
// that never dips below it has no single period, and detection returns none rather than the
// global minimum — the difference between "quiet but real" and "the least bad of nothing".
inline constexpr double kPeriodDetectThreshold = 0.12;
// The longest lag searched, in frames — THE one derivation of it. A probe block is twice this,
// and `periodAnalysisSpan`'s minimum is one block; both read this rather than re-deriving the
// same expression, so "choosing the loop never narrows the search band" is a fact and not a
// coincidence between two literals.
inline std::size_t longestLagFrames(int sampleRate) {
return static_cast<std::size_t>(static_cast<double>(sampleRate) / kPeriodDetectMinHz);
}
// How many blocks across the sample are estimated independently, and how far apart two of them
// may land and still be called the same period. Agreement is what separates a genuinely
// periodic source from one whose opening happens to look periodic.
//
// Probes are placed by POSITION and may overlap: what the rule needs is estimates from
// different places in the source, and two blocks a full longest-lag apart already differ by a
// whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy
// of the other at any period searched. Requiring DISJOINT blocks instead left every source
// under ~4x the longest lag with a single probe and so with no agreement to check at all.
inline constexpr int kPeriodDetectProbes = 4;
inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median
// Estimates the fundamental period of `pcm[from, from+count)` at `sampleRate`. Cost is bounded
// by the constants above, not by the span length: at most kPeriodDetectProbes blocks of ~2 x
// the longest searched lag are analysed however long the span is. Allocates; never call from
// process(). An out-of-range span estimates nothing and returns none.
//
// Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes
// disagree — the caller's documented fallback is the fixed-window splice geometry.
//
// A STRICT MAJORITY of the probes that CARRIED SIGNAL must agree. Silence is excluded from that
// denominator and a failure to find a period is not: a silent block is no evidence either way,
// whereas a block that carries signal and repeats at no lag is evidence against a single period.
// A capture with a silent head or tail therefore still detects, while a mostly-noise source with
// one pitched burst is refused rather than accepted on that burst alone. A LONE piece of
// evidence — the whole span too short for a second probe position, or every other probe silent —
// is accepted on the absolute threshold alone, because there is nothing to rule against it and
// refusing would deny every short one-shot a period.
//
// The answer is NOT monotone in span length, and cannot be made so: no rule that refuses a
// two-and-two split at four probes can also accept a lone probe unconditionally, and the probe
// count steps at 3x, 4x, 5x and 6x the longest lag before saturating. What IS pinned, by a
// length sweep in the tests, is that a STATIONARY source detects at every length — a source
// whose period varies by more than kPeriodDetectAgreeTolerance is the only class that moves
// with the count, and refusing it is this contract's own answer.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t from, std::size_t count);
// The whole source.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate);
// The frames detection should analyse for a capture that carries a sustain loop, and the reason
// the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the
// splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise
// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it is
// at least one full probe block — `2 * longestLagFrames(sampleRate)`, the span below which
// detectPeriod starts shortening its own search band — so choosing the narrower span never costs
// search-band WIDTH. It can still change the ANSWER: the agreement rule rules on content, so a
// source periodic over most of its length whose loop region is noisy detects whole and refuses
// over the loop. That is the intent — the loop is what a Gate voice plays.
// Anything else (no loop, an out-of-range span, a short one) yields the whole source.
//
// It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the
// loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span
// this cache was derived from — whereas play mode's exclusion from live delivery is a listed,
// reversible decision (deck_groups' deckParamCommit). Keying a load-time cache on it would work
// today and silently serve a stale period the day that decision is revisited.
//
// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is
// on a cache input, not a second validity rule, and it refuses rather than repairs the same way.
struct AnalysisSpan {
std::size_t from = 0;
std::size_t count = 0;
};
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate);
} // namespace reasampler::instrument::engine
+84 -28
View File
@@ -1,10 +1,12 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
//
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
// sample (source rate, duration preserved). One active read tap advances by the shift
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
// one window — clamped to the filled span so it never lands in unwritten silence — refined
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per source
// frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame,
// so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one
// frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump (one
// window, or the nearest whole number of source periods to it once setSourcePeriod names one)
// — clamped to the filled span so it never lands in unwritten silence — refined
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
@@ -25,6 +27,23 @@ constexpr double kPi = 3.14159265358979323846;
} // namespace
std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames,
double periodFrames) {
if (windowFrames <= 1 || maxJumpFrames < 1) return windowFrames;
if (!(periodFrames > 0.0)) return windowFrames;
if (periodFrames > static_cast<double>(maxJumpFrames)) return windowFrames;
std::int64_t n = static_cast<std::int64_t>(
static_cast<double>(windowFrames) / periodFrames + 0.5);
if (n < 1) n = 1;
std::int64_t jump = static_cast<std::int64_t>(periodFrames * static_cast<double>(n) + 0.5);
while (jump > maxJumpFrames && n > 1) {
--n;
jump = static_cast<std::int64_t>(periodFrames * static_cast<double>(n) + 0.5);
}
if (jump < 1 || jump > maxJumpFrames) return windowFrames;
return jump;
}
void PitchShifter::configure(std::int64_t windowFrames) {
window_ = windowFrames;
if (window_ <= 1) {
@@ -36,8 +55,11 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fading_ = false;
fadePos_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
period_ = 0.0;
jump_ = jumpMax_ = 0;
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
@@ -60,6 +82,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
// The delay band is (dHigh_ - dLow_) wide and the search can add up to maxLag_ on either
// side; one frame more than that and a jump could land exactly ON a trigger boundary.
jumpMax_ = std::max<std::int64_t>(1, dHigh_ - dLow_ - maxLag_ - 1);
fadeLen_ = 0;
reset();
}
@@ -85,15 +110,23 @@ void PitchShifter::reset() {
}
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::setSourcePeriod(double periodFrames) {
period_ = periodFrames > 0.0 ? periodFrames : 0.0;
jump_ = window_ > 1 ? periodAlignedJump(window_, jumpMax_, period_) : 0;
}
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// ratio-feedRate per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
@@ -158,6 +191,10 @@ void PitchShifter::setShiftRatio(double ratio) {
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
}
void PitchShifter::setFeedRate(double rate) {
if (rate > 0.0) feedRate_ = rate;
}
double PitchShifter::readTap(double pos) const {
// Fractional linear interpolation with ring wrap.
double p = pos;
@@ -192,7 +229,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
if (jump > maxJump) jump = maxJump;
// Shortening a period-aligned jump to fit must land on a SHORTER MULTIPLE, not on the
// raw bound — a clamped jump is an unaligned one, which is the whole failure this
// module now avoids. With no period known (or none fitting) this is the bare clamp.
if (jump > maxJump) jump = periodAlignedJump(maxJump, maxJump, period_);
if (jump < 1) jump = 1;
}
// The correlation reference reads FORWARD from the tap; keep it strictly behind the
@@ -266,18 +306,19 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
while (p >= len) p -= len;
posA_ = p;
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
// so they always keep the full fade.
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts,
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those
// always keep the full fade.
//
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
// applies at every ratio (including unity, since delay now drains at unity too).
// either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies
// at every ratio (including unity, since delay now drains at unity too).
fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
if (drainRate > 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
@@ -310,14 +351,27 @@ void PitchShifter::applySplice(const SpliceEvent& ev) {
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
}
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr, true); }
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
return processImpl(in, &master);
return processImpl(in, &master, true);
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
AudioSample PitchShifter::processNoInput() { return processImpl(0.0f, nullptr, false); }
AudioSample PitchShifter::processNoInputLinked(const SpliceEvent& master) {
return processImpl(0.0f, &master, false);
}
void PitchShifter::writeFrame(AudioSample in) {
if (window_ <= 1 || tailFrozen_) return;
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
if (++writePos_ >= ringLen_) writePos_ = 0;
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, bool write) {
if (window_ <= 1) return write ? in : 0.0f; // pass-through (unconfigured / degenerate)
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
@@ -325,8 +379,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
// below run unchanged over the frozen content.
if (!tailFrozen_) {
// below run unchanged over the frozen content. A starved stretch frame (`write` false) takes
// the identical shape: no input was due this output frame, so there is nothing to write.
if (write && !tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
}
@@ -357,9 +412,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
splice(-jump_, d);
}
}
} else {
@@ -372,14 +427,15 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
splice(-jump_, d);
}
}
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
if (!tailFrozen_) {
// Advance heads: write head one frame (parked while tail-frozen or starved), tap(s) by the
// shift ratio.
if (write && !tailFrozen_) {
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
}
+77 -6
View File
@@ -1,11 +1,18 @@
#pragma once
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core).
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// `readPos_ += ratio_` resample path).
// relocated by a nominal jump, refined by a cross-correlation search so the new read point is
// waveform-aligned, then old/new taps crossfade (raised-cosine). The nominal jump is a whole
// number of the SOURCE's own periods when setSourcePeriod names one (pitch-synchronous OLA),
// and the fixed window otherwise.
//
// The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio =
// pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via
// process() holds duration and moves pitch; feeding faster/slower via writeFrame() /
// processNoInput() moves duration at whatever pitch the tap is set to. Nothing here resamples
// to preserve duration — the splice/overlap-add IS the pitch-preserving mechanism, which is
// what the "WDL_Resampler is not a Preserve engine" invariant asks for.
//
// Regression history — do not revert any of these:
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
@@ -56,6 +63,21 @@ struct SpliceEvent {
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// The nominal splice jump for a source whose period is known: the multiple of `periodFrames`
// nearest `windowFrames` that still fits `maxJumpFrames`. Falls back to `windowFrames` — the
// pre-PSOLA geometry, exactly — whenever the period is unknown (<= 0) or too long for even one
// whole period to fit, which is the documented degradation for inharmonic, polyphonic,
// percussive and noise sources.
//
// Why this is the whole fix: a splice can only phase-align on a landing point that is a whole
// number of source periods away, and the correlation search only reaches [0.75, 1.25] windows.
// Periods with no multiple in that one interval — f < ~16 Hz, and 26.7-32 Hz at a 50 ms
// window — could never align, however good the search was. Making the NOMINAL a multiple puts
// an aligned point at the centre of the search rather than hoping one falls inside it. The
// jump is rounded to whole frames; the search's own sub-sample refinement absorbs the residue.
std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames,
double periodFrames);
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// two rings stay sample-aligned.
@@ -89,6 +111,25 @@ public:
// ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio);
// Source frames written per output frame — 1.0 unless the caller is stretching. Used ONLY
// to size a splice crossfade safely: the outgoing tap closes on the write head at
// (ratio - feedRate) per frame, so a fade sized against an assumed 1.0 overruns when the
// source is fed slower than the output runs and the tail of the fade reads lapped content.
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate);
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
// number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
// default, so a caller that never calls this sees no change.
// Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately
// does not link); this is a couple of divisions and is safe to call at note-on.
// Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source.
void setSourcePeriod(double periodFrames);
// The nominal jump splices currently use — window() unless a source period retuned it to
// the nearest whole-period multiple, which can land either narrower or wider than window().
std::int64_t spliceJump() const { return jump_; }
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
@@ -103,6 +144,20 @@ public:
// their ring state advances in lockstep. RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
// Writes one source frame WITHOUT producing an output frame — the stretch path's surplus
// input when the source is consumed faster than the output runs. No splice can fire here:
// splices are decided on the read side. No-op while unconfigured or tail-frozen, and it
// deliberately leaves lastSplice_ alone so a linked follower's schedule is unaffected.
// RT-safe.
void writeFrame(AudioSample in);
// Produces one output frame WITHOUT consuming a source frame — the stretch path's starved
// output frame when the source is consumed slower than the output runs. Identical to
// process()/processLinked() in every other respect. Returns 0 while unconfigured (there is
// no input to pass through). RT-safe.
AudioSample processNoInput();
AudioSample processNoInputLinked(const SpliceEvent& master);
const SpliceEvent& lastSplice() const { return lastSplice_; }
// Call once the source stream is exhausted — no real frame remains to feed process().
@@ -137,7 +192,9 @@ private:
void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
// `write` false is the starved stretch frame: read/splice/advance the taps, but consume no
// input and hold the write head (the same shape tail-freezing already takes).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write);
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -152,6 +209,19 @@ private:
// ratio-scaled at splice time so an up-shift's outgoing
// tap can never drain into the writer mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window)
std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
// inside [dLow_, dHigh_] at the worst search lag, so a
// period-sized jump can never land back on a trigger and
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
// jump_==jumpMax_ a DOWN-splice's correlation read comes
// within ~41 frames of the write head (measured: the
// exact ring/lag/corrFrames_ geometry at the product
// window, worst case over every lag the search reaches) —
// real margin, not zero, but tight enough that widening
// maxLag_, corrFrames_ or jumpMax_ without re-deriving
// this bound risks reading unwritten ring content.
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer by construction at an up-splice)
@@ -161,6 +231,7 @@ private:
// clamps its up-jump to this so it never lands in
// unwritten silence
double ratio_ = 1.0; // current shift ratio (>0)
double feedRate_ = 1.0; // source frames written per output frame; splice-fade only
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
// at the top of every frame, set on a splice
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
+23 -1
View File
@@ -148,6 +148,11 @@ struct FilterParams {
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
// instrument's stored scalar and the live block all default from here, so a blob predating the
// field and a block published before the first note can never disagree about it.
inline constexpr double kKeyTrackDefault = 1.0;
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
// on this; the Preserve product default is layered on at (de)serialization, see
@@ -158,6 +163,15 @@ struct PlayParams {
TriggerParams trigger; // Trigger play span
AhdParams trigAhd; // Trigger amp
PitchEngine pitchEngine = PitchEngine::Varispeed;
// Playback RATE, as source frames consumed per output frame. Under Varispeed it is one more
// factor of the read increment, so it moves pitch and duration together; under Preserve it
// drives duration alone and the shifter holds the pitch. Latched at note-on either way (the
// loop fold and the contour scale it composes with are both note-on folds), and clamped by
// the stretcher's own clampStretchRate — never here. 1.0 is the bare engine, bit for bit.
double playRate = 1.0;
// A baseline pitch offset in semitones, folded into the note's ratio beside key-tracking and
// the velocity->pitch transpose. Live on a sounding voice under both engines.
double pitchOffsetSemitones = 0.0;
PitchEnvParams pitchEnv;
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
@@ -258,12 +272,20 @@ struct SampleData {
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// (never per frame). Default flat y=1 — every velocity plays at unity.
VelocityCurve velocityCurve = VelocityCurve::flat();
// The source's own fundamental period in SOURCE frames, which makes Preserve's splices
// pitch-synchronous (pitch_shift.h). DERIVED from the PCM at load, not authored and never
// persisted — a cache, not state, so it takes no rung of the payload ladder. 0 means
// unknown (nothing detected it, or the source has no single period) and restores the
// fixed-window splice geometry byte for byte, which is why a hand-built SampleData is
// still exactly the bare engine.
double sourcePeriodFrames = 0.0;
PlayParams play;
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
+132
View File
@@ -0,0 +1,132 @@
#pragma once
// time_stretch — the Preserve engine's TIME half: how fast the source is consumed, given a
// playback rate. It pairs with pitch_shift's PITCH half (how fast the ring's read tap runs);
// the two rates are independent over one delay ring, and only their difference reaches the
// splice machinery. Header-inline: every member sits on the per-voice-per-sample feed.
#include <cstdint>
#include "core/instrument/engine/loop/loop_span.h"
namespace reasampler::instrument::engine {
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame
// source frames) — the RT-safety argument for feeding a variable count at all.
//
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output
// frames (the tap's delay drifts across one nominal jump at that per-frame rate); the shifted
// tone's own period is `sourcePeriod / shift` output frames. Whenever the recurrence interval
// is shorter than that period, a splice lands inside a single perceived cycle and the
// correlation search has less than one period to align against. Measured at rate 4.0, shift
// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785
// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling
// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval
// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter)
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it
// checks, since splice debris fools that estimator into reading the wrong period on a render
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
//
// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of
// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead
// (pitch_shift.h), and that answer can land NARROWER than window() — as low as ~0.63*window for
// some periods — which SHRINKS the interval and moves the failure threshold EARLIER, not later.
// There is no single closed-form floor for this case (the jump is itself a function of P), so
// read it at the concrete corner instead: at P=1470 (30 Hz at 44.1k) the same rate 2.0/shift
// 0.25 corner's jump narrows from window (2205) to 1470, and its interval from 1260 to
// 1470/1.75 = 840. Independently, at the plain (no time-stretch) rate 1.0 case, solving this
// same inequality for shift at P=1470 puts the failure threshold at shift = P/(jump+P): 0.4
// (-16 st) at the fixed-window jump (2205), 0.5 (-12 st) at the pitch-synchronous jump (1470) —
// the geometry fix that lets 30 Hz align AT ALL moves this unrelated cadence inequality's own
// trip point from roughly -16 st to roughly -12 st for the same source. Do NOT read this as a
// proven regression: the inequality above was calibrated for RANDOM-PHASE (unaligned) splices,
// and a pitch-synchronous splice is waveform-aligned by construction, which the inequality does
// not model — whether the shorter interval still produces audible debris once every splice
// lands in phase is what pitch_shift_tests' own P=1470 cadence-collapse-band measurement
// answers, not this derivation. Do not narrow kStretchRateMin/kStretchRateMax in response to
// this: sub-50 Hz sine material is first-class product material, not an edge case, and a
// narrower range does not fix a floor it does not reach.
//
// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
// CLOSED for any source whose period is detected, but the geometry is worth keeping because it
// is what the fixed-window fallback still lives under. A splice relocated the tap by the
// nominal window refined by a search over +/- window/4, so the reachable relocation distances
// were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source
// periods inside that interval. The interval is 0.5*window wide, so any period <= window/2
// always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers
// periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was
// reachable by nothing. Because both the interval and the period scale with the sample rate,
// that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and
// 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch
// stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at
// rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone
// itself landed 7.4% flat (-133 cents).
//
// The fix is not a wider window: it is a nominal jump that is a whole number of the source's
// own periods, so an aligned landing point exists by construction (pitch_shift.h's
// periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and
// 0.00%, and 29 Hz renders at +0.0 cents — all from `preserve_low_frequency_tests` (Release,
// hand-run; it is not in the gated ctest set), the same harness/config as the 3.6%/15.5%/-133
// cents readings above. The gated suite's own number for this is the floor-relative excess in
// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different
// quantity from the raw percentages here. What survives: a period longer than the reachable
// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no
// single period falls back to it by design (periodAlignedJump, pitch_shift.h).
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
// run the tap backward). 1.0 in gives exactly 1.0 out, which is what keeps the unity read
// bit-identical.
inline double clampStretchRate(double rate) {
if (!(rate > 0.0)) return 1.0;
if (rate < kStretchRateMin) return kStretchRateMin;
return rate > kStretchRateMax ? kStretchRateMax : rate;
}
// One Preserve voice's source-feed schedule: a fractional source cursor answering, per OUTPUT
// frame, which whole source frames fall due. At rate 1.0 that is exactly one frame per output
// frame with no residue carried — bit for bit the pre-stretch feed.
class StretchCursor {
public:
// `frame` is where the ring prime stopped; the per-frame feed continues there.
void start(std::int64_t frame) {
frame_ = frame;
debt_ = 0.0;
}
// Adds one output frame's worth of source at `rate` and returns how many whole source
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
// lives here rather than at the caller because this return value is the loop bound.
std::int64_t due(double rate) {
debt_ += clampStretchRate(rate);
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
debt_ -= static_cast<double>(whole);
return whole;
}
// The next due source frame, wrapped into the sustain loop, advancing the cursor past it.
// Advances even past the playable span — the caller freezes the shifter's writer there, and
// a cursor that stalled instead would re-feed one frame forever.
std::int64_t next(const loop::ResolvedLoop& lp) {
if (lp.active) {
while (frame_ >= lp.end) frame_ -= lp.length;
}
return frame_++;
}
std::int64_t frame() const { return frame_; }
private:
std::int64_t frame_ = 0;
double debt_ = 0.0; // fractional source frames carried into the next output frame
};
} // namespace reasampler::instrument::engine
+65 -36
View File
@@ -18,7 +18,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
}
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
double stretchRate, double keyTrack, double lengthFraction) {
// Before any state reset, record the pre-cut reference (last rendered output) and mark
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
@@ -52,13 +53,30 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
sample_ = &sample;
const PlayParams& p = sample.play;
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below).
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ rather than
// costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed read-rate
// bias and Preserve shift amount both derive from it below).
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones);
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for
// a rate this moves. Clamped once here so the read head's increment and the feed cursor's
// debt accumulate the SAME value: they must stay exactly one window apart for the note's
// whole life.
stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
// Keyed on the read path this note will ACTUALLY take, which is not the same question as
// the stored engine: advanceFrame runs the Preserve branch only while the shifters are
// configured, and a Preserve voice whose shifters were never sized falls back to the
// varispeed read. Rate has to reach the increment there too, or that fallback would ignore
// the control outright — the predicate is spelled the same way advanceFrame spells it.
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
recomputeBaseRatio();
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
// Pitch 0 it is a division by exactly 1.0.
pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_;
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
// rather than starting a voice already off the end.
@@ -103,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else {
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
// the whole take.
double frac = effectiveLengthFraction(p);
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
if (frac > 1.0) frac = 1.0;
std::int64_t playLen = static_cast<std::int64_t>(
@@ -115,23 +132,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
if (playLen > postStart) playLen = postStart;
playEnd_ = start + playLen;
trigSpan = playLen;
ampAhd_.configure(playLen, p.trigAhd);
ampAhd_.configure(playLen, rateFittedAhd(p.trigAhd));
}
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
// rate, so its two domains already coincide.
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
// Strictly better than the un-converted source-frame span it replaced.
const double pitchSpan =
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
? static_cast<double>(postStart)
: static_cast<double>(postStart) / baseRatio_;
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
// stages lay 1:1 over the waveform from the start point. The source->output conversion, and
// why it is only first-order, are pitchEnvSpanFrames' own (voice.h).
pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv);
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
@@ -165,7 +172,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
} else {
filterAhd_.configure(trigSpan, p.filter.trigEnv);
filterAhd_.configure(trigSpan, rateFittedAhd(p.filter.trigEnv));
}
filter_.reset();
updateFilterCutoffBase(note);
@@ -234,7 +241,15 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
// playable span).
feedPos_ = p;
stretch_.start(p);
shiftL_.setFeedRate(stretchRate_);
shiftR_.setFeedRate(stretchRate_);
// Pitch-synchronous splices: the period was detected once at load (period_detect,
// which this library deliberately does not link — the loader hands the answer down on
// SampleData). 0 restores the fixed-window geometry, so a capture with no single
// period plays exactly as it always did.
shiftL_.setSourcePeriod(sample.sourcePeriodFrames);
shiftR_.setSourcePeriod(sample.sourcePeriodFrames);
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is already exhausted at prime time.
shiftL_.freezeTail();
@@ -251,15 +266,26 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
//
// live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class,
// delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The
// latched stretchRate_ is what stageFitRate carries into every conversion below, so a
// stage-time move mid-note lands in this note's own rate domain rather than resetting it.
const bool gate = (playMode_ == PlayMode::Gate);
// The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_
// up as one more factor of next frame's read increment, Preserve as the shifter's transpose.
// Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate
// both of them are fitted against — a stale offset here would fit them to the previous move.
pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones);
recomputeBaseRatio();
if (snap) {
if (gate) env_.snapLive(live.adsr);
else ampAhd_.snapLive(live.ampAhd);
pitchEnv_.snapLive(live.pitchEnv);
else ampAhd_.snapLive(rateFittedAhd(live.ampAhd));
pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv);
} else {
if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
pitchEnv_.applyLive(live.pitchEnv);
else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd));
pitchEnv_.applyLive(pitchEnvSpanFrames(), live.pitchEnv);
}
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
@@ -271,10 +297,10 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
if (snap) {
if (gate) filterEnv_.snapLive(live.filterEnv);
else filterAhd_.snapLive(live.filterAhd);
else filterAhd_.snapLive(rateFittedAhd(live.filterAhd));
} else {
if (gate) filterEnv_.applyLive(live.filterEnv);
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd));
}
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
@@ -312,15 +338,18 @@ void Voice::retune(int note) {
// legato phrase is one gesture, one strike (classic mono-synth behavior).
if (!active_ || sample_ == nullptr) return;
note_ = note;
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
// first note's domain — consistent with "touch nothing else," but the drift lives here.
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span
// (pitchEnvSpanFrames, whose base rate this deliberately does not move), so a slide leaves
// that envelope on the first note's domain — consistent with "touch nothing else," but the
// drift lives here.
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
// one gesture, one strike.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
// one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved.
recomputeBaseRatio();
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
if (filterOn_) updateFilterCutoffBase(note);
// stretchRate_ (Preserve's duration control) is untouched here too — it is a note-on latch
// like velocityGain_, not a per-note property to re-resolve on a legato slide.
}
void Voice::release() {
+179 -50
View File
@@ -19,6 +19,7 @@
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/time_stretch.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler {
@@ -44,17 +45,25 @@ inline double pitchRatio(int note, int rootNote) {
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
// At the root note the offset is 0 regardless of keyTrack.
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
inline constexpr double kLatchFromSnapshot = -1.0;
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0);
}
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
// 2^(semitones/12). Exactly 1.0 at zero — and it SKIPS the pow there, so an unset offset
// transposes nothing and costs nothing.
inline double semitoneRatio(double semitones) {
return (semitones == 0.0) ? 1.0 : std::pow(2.0, semitones / 12.0);
}
// The velocity->pitch transpose, which the voice folds into baseRatio_ once at note-on. A curve
// flat at 0 — the default — yields EXACTLY 1.0 at every velocity.
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
return semitoneRatio(curve.eval(static_cast<double>(velocity)) *
kVelocityPitchRangeSemitones);
}
// One octave expressed in the cutoff control's normalized domain, read out of the filter
@@ -108,7 +117,26 @@ public:
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
// the difference-seeded declick compensation on the first frame after the restart (see
// kDeclickDecay above). A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
//
// `stretchRate` is the playback rate — source frames consumed per output frame, clamped to
// [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an argument, not
// a member set separately) because the loop fold and the contour scale it composes with are
// both note-on folds. Under Preserve it is the stretcher's feed rate and duration alone moves;
// under Varispeed it folds into the read increment beside key-tracking, so pitch moves with
// it. 1.0 is the bare engine, bit for bit, in both. Defaulted so a caller with no live block
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
// possibly-stale copy of it.
//
// `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
// class and arrive the same way, for the same structural reason. Negative = not supplied,
// which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
// both are non-negative by domain, so the sentinel can never collide with a real one.
// VoiceEngine::startVoice always supplies them, resolved from the published block when there
// is one; the sentinel is for a caller that has no block to consult.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
double lengthFraction = kLatchFromSnapshot);
// Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
@@ -176,6 +204,65 @@ public:
}
private:
// THE fold of every pitch factor that is constant for the note into one number, so
// advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
// always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
// and the Rate ratio — which start() zeroes out of this product when the note is running the
// Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
}
// The rate the read head consumes SOURCE at, counting only the factors whose stage-time
// coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
// transposes inside the shifter and never touches the read. Under Varispeed both Rate and
// Pitch are factors of the read increment and both are compensated: they are two views of one
// multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
// velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
// sounds, and compensating them would move every note off the root.
double stageFitRate() const {
return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
}
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
// sustain-less envelopes are evaluated in (sourceOffset()). The read stretches the source
// span those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate —
// multiplying by the read rate is exactly that conversion. A fit of exactly 1.0 (Rate 100 %,
// Pitch 0 st) returns the argument untouched, which is what keeps the unity render
// bit-identical.
AhdParams rateFittedAhd(const AhdParams& a) const {
const double fit = stageFitRate();
if (fit == 1.0) return a;
AhdParams out = a;
out.attackFrames =
static_cast<std::int64_t>(static_cast<double>(a.attackFrames) * fit + 0.5);
out.decayFrames =
static_cast<std::int64_t>(static_cast<double>(a.decayFrames) * fit + 0.5);
return out;
}
// The pitch AHD's span. That envelope counts OUTPUT frames while its Hold fraction is taken
// against the playable SOURCE span, so the span converts by the rate the read head consumes
// source at. Divides by that alone though the Varispeed read rate is really baseRatio_ x
// envFactor: a deep pitch envelope makes it a first-order approximation, not exact.
//
// Shared by note-on and every live re-application, so a live Pitch move re-fits the envelope
// rather than leaving it on the offset the note started at. Only that live factor is
// re-read — pitchSpanBaseRate_ has it divided out — which is what leaves a legato retune's
// documented drift (retune) exactly where it was.
std::int64_t pitchEnvSpanFrames() const {
if (sample_ == nullptr) return 0;
const double postStart = static_cast<double>(
static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
const double readRate =
preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
const double span = (readRate > 0.0) ? postStart / readRate : postStart;
return static_cast<std::int64_t>(span + 0.5);
}
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
// its opening value.
@@ -184,9 +271,9 @@ private:
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
// the voice.
// is evaluated at the source offset (readPos - startFrame), which is why its stage frames are
// fitted to the read rate at configure time (rateFittedAhd). Sets amplitudeDone_ on finish so
// advanceFrame frees the voice.
double tickAmplitude() {
double amp;
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
@@ -441,56 +528,78 @@ private:
// and the amp envelope shapes the filtered result (drive included).
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// Feed the shifters the source stream at unity rate (duration held) and transpose
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
// rings were primed with that window at start()), under the same sustain-loop wrap
// rule, reading integer source frames (nothing to interpolate). Past the last real
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
if (loop.active) {
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
}
// feedPos_ runs one window ahead of readPos_; the last real source frame is
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
// the source is exhausted — feeding the held last sample instead would give the
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
// cadence, growing toward the note end). Freezing the shifter's writer means no
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
// the source is FED at stretchRate_, and the tap is SHIFTED by
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
// primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames into the ring — no RATE-DEPENDENT interpolation
// (unlike Varispeed's readPos_ below). The shifter's own read tap still carries a
// splice's sub-sample `frac` (pitch_shift.cpp), so it interpolates on every read,
// splice or no; that constant fractional delay is not a rate coupling.
const bool stereoOut = stereo && haveR && shiftR_.configured();
// The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate.
// Once the feed reaches that bound the source is exhausted — feeding the held last
// sample instead would give the splice correlation a DC plateau it can't align on
// (periodic troughs at the splice cadence, growing toward the note end). Freezing the
// shifter's writer means no padding ever enters the ring, so the splice machinery
// keeps recycling the frozen all-real tail — a continuous tone through the voice's
// own end. The sustain-loop path never gets here: the wrap keeps the cursor inside
// the loop forever.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
const AudioSample feedL =
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
if (stereoOut) shiftR_.setShiftRatio(shift);
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
// written without producing output; the last rides the ordinary 1-in-1-out
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const std::int64_t due = stretch_.due(stretchRate_);
AudioSample feedL = 0.0f, feedR = 0.0f;
bool fed = false;
for (std::int64_t k = 0; k < due; ++k) {
if (fed) { // an earlier frame of this batch: write-only, no output
shiftL_.writeFrame(feedL);
if (stereoOut) shiftR_.writeFrame(feedR);
}
const std::int64_t q = stretch_.next(loop);
if (q >= feedBound) {
shiftL_.freezeTail(); // idempotent; input ignored while frozen
if (stereoOut) shiftR_.freezeTail();
feedL = feedR = 0.0f;
} else {
const double xw = crossfadeWeight(loop, static_cast<double>(q));
feedL = crossfadedSource(pcm, loop, q, xw);
if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw);
}
fed = true;
}
const double shiftedL =
fed ? static_cast<double>(shiftL_.process(feedL))
: static_cast<double>(shiftL_.processNoInput());
outL = shiftedL;
if (stereo) {
if (haveR && shiftR_.configured()) {
if (stereoOut) {
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
// splice decisions via processLinked — one correlation search, one lag, one
// splice schedule for both channels (standard stereo SOLA). An independent
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
// every splice: stereo image wander at the splice cadence + mono-sum
// combing. Each shifter is still processed EXACTLY ONCE per output frame
// (never twice — that would advance its heads twice and corrupt the state).
// (never twice — that would advance its heads twice and corrupt the state);
// the batch's earlier frames go through writeFrame, which produces none.
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
// primes it for genuinely stereo samples, and a stale un-primed ring must
// not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR =
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
fed ? static_cast<double>(
shiftR_.processLinked(feedR, shiftL_.lastSplice()))
: static_cast<double>(
shiftR_.processNoInputLinked(shiftL_.lastSplice()));
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
// shifted value from the mono feed; mirror it to R. Do NOT call
@@ -498,9 +607,13 @@ private:
outRlocal = shiftedL;
}
}
++feedPos_;
// Preserve advances the read head at the SOURCE rate (duration preserved).
ratio_ = 1.0;
// Preserve advances the read head at the STRETCH rate — the one duration control.
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
// therefore stays a source-frame fact and scales by construction.
//
// The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
// this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
ratio_ = stretchRate_;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
@@ -604,8 +717,22 @@ private:
bool releasing_ = false;
int note_ = 0;
double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
double baseRatio_ = 1.0; // recomputeBaseRatio's product: every constant pitch factor
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
// Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
// snapshot so a legato retune and a live block re-apply the note's own value; a published
// move reaches the next note only.
double keyTrack_ = kKeyTrackDefault;
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
// note-on beside rateRatio_, which start() resolves from the same predicate.
bool preserveRead_ = false;
// baseRatio_ with the live Pitch factor divided back out, latched at note-on: what
// pitchEnvSpanFrames multiplies the CURRENT offset onto. Exact at Pitch 0 (the factor is
// exactly 1.0), which is what keeps the unity span bit-identical.
double pitchSpanBaseRate_ = 1.0;
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
@@ -676,9 +803,10 @@ private:
//
// The shifter rings are primed at start() with the first window of the actual upcoming
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
// silence, and splices always land in real history. feedPos_ is the integer source frame
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
// silence, and splices always land in real history. stretch_ is the integer source frame
// fed to the shifters next plus the fractional rate debt; it runs one window ahead of
// readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one
// window apart at every stretch. Once it passes the last real frame (Gate: sample end;
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
// primeBuf_ is the presized scratch the prime stream is assembled into.
@@ -686,7 +814,8 @@ private:
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
PitchShifter shiftR_;
std::int64_t feedPos_ = 0;
instrument::engine::StretchCursor stretch_;
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
std::vector<AudioSample> primeBuf_;
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
+10 -1
View File
@@ -54,7 +54,16 @@ void VoiceEngine::applyLiveToActive() {
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
// THE read of the note-on-latched commit class, and the only one: a published block outranks
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
// touches none of these three — so a move reaches the next note and no sounding one.
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack;
// Already spline-folded in the block; the snapshot branch folds here so the two agree.
const double lengthFraction =
haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play);
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}
+2 -1
View File
@@ -40,7 +40,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
reasampler_pure_library(sample_map
SOURCES sample_map.cpp
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law
musical_division)
musical_division
PRIVATE period_detect)
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
# both halves of the mapping/codec split where the frozen-format assertions live.
reasampler_test(sample_map LINK sample_map component_state_io)
+29 -4
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v14) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v16) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -104,7 +104,7 @@ namespace reasampler::instrument::map {
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// v13 is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
@@ -120,7 +120,7 @@ namespace reasampler::instrument::map {
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
//
// v14 (CURRENT WRITE FORMAT) is v13 PLUS the resample bake's Hold division, appended after the
// v14 is v13 PLUS the resample bake's Hold division, appended after the
// hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0
// Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields —
// never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob
@@ -129,6 +129,23 @@ namespace reasampler::instrument::map {
// A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record —
// the same revive discipline the v13 hard-flag tails follow, and for the same reason.
//
// v15 is v14 PLUS ONE byte: the master-bus limiter's enable, appended after the Hold
// division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed,
// which is also the field's product default, so a project saved before the limiter existed
// reopens with the limiter off and sounding identical. It carries the Hold's revive
// discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte
// would otherwise reset the record the Hold's own revive just preserved.
//
// v16 (CURRENT WRITE FORMAT) is v15 PLUS TWO 8-byte LE doubles, appended after the limiter
// byte: the playback RATE as a ratio, then the baseline PITCH offset in semitones. A v15-or-
// older blob is a strict prefix and lifts to 1.0 / 0.0 — unity rate and no offset, which is
// what every instance before them played, so it reopens bit-identical. Both are rate-free
// values, so nothing about them is resolved against the project rate. Same revive-and-drain
// discipline as the two tails above. The two wire GUARDS deliberately differ, and
// readRateAndPitchOffset owns why: the offset is range-checked here because nothing downstream
// bounds it, while the rate is only checked for usability because its range belongs to the
// engine's own clamp.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
@@ -160,7 +177,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division
inline constexpr std::uint32_t kParamsPayloadVersion = 16; // v15 + Rate and the pitch offset
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -192,6 +209,14 @@ inline constexpr std::uint32_t kParamsSplineVersion = 13;
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;
// v14 + the master-bus limiter enable; the appended byte branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsLimiterVersion = 15;
// v15 + the playback rate and the baseline pitch offset; the appended pair branches on THIS,
// never on kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsRateVersion = 16;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+69 -9
View File
@@ -8,6 +8,7 @@
#include <cmath> // std::isfinite (wire-value validation)
#include <utility> // std::move
#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound)
#include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
@@ -221,24 +222,76 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) {
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}
// Read the v14 bake Hold. Same revive discipline as readHardFlags directly above, and for the
// same reason: this tail reaches no audio path, so a blob truncated inside it must cost the
// Hold alone and not reset the whole record that parsed cleanly ahead of it. It sits LAST, so
// a truncation stranding the hard flags strands this too — reviving in only one of the two
// would still wipe the record.
// THE shared ending for every appended tail past the hard flags: revive, then DRAIN. Both
// halves are load-bearing and neither is optional.
//
// Revive, because these tails reach no audio path — a blob truncated inside one must cost
// that field alone and not reset the whole record that parsed cleanly ahead of it. An r.ok
// already false on entry (an earlier, unrelated field genuinely truncated) is left alone;
// that failure is not this tail's to forgive.
//
// Drain, because a FAILED read does not advance the cursor. The bytes it rejected are still
// sitting there for the NEXT tail to consume as its own — a truncated Hold whose two
// surviving exponent bytes arrive at the limiter byte reads back as ENABLED. Reviving without
// draining does not degrade to absent; it fabricates. Every tail added after this one must
// end here too.
//
// Returns true when the caller must abandon its field.
bool reviveTruncatedTail(ByteReader& r, bool enteredOk) {
if (r.ok) return false;
if (enteredOk) r.ok = true;
drainUnaligned(r);
return true;
}
// Read the v14 bake Hold.
void readBakeHold(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const std::int32_t exponent = r.i32();
const std::uint8_t modifier = r.u8();
if (!r.ok) {
if (enteredOk) r.ok = true;
return;
}
if (reviveTruncatedTail(r, enteredOk)) return;
// makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung
// rather than an unrepresentable one — never a memcpy into the type.
p.bakeHold = note::makeDivision(exponent, static_cast<note::DivisionModifier>(modifier));
}
// Read the v15 limiter enable. Bypassed is what a truncation means and what the field already
// holds, so a missing byte costs nothing beyond the enable itself.
void readLimiterEnable(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const std::uint8_t flag = r.u8();
if (reviveTruncatedTail(r, enteredOk)) return;
p.limiterEnabled = (flag != 0);
}
// Read the v16 rate + pitch-offset pair. A truncation, or either value unusable, leaves the
// neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16
// blob means and what every instance before them played.
//
// The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather
// than merely admitted: the stretcher owns its range, so a second copy of the bounds here could
// disagree with it — but a value that only playback clamped would re-serialize out of range and
// leave the stored value disagreeing with the needle, and with the host normalization once the
// instrument reports parameters. Finiteness stays a separate test in front of it, because
// corruption is not an out-of-range value: an infinite rate degrades to the neutral, where a
// merely-too-fast one clamps to the bound. The offset gets a real range test instead, because
// nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild
// exponent there is UB on the per-sample path.
void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const double rate = bitsToDouble(r.u64());
const double offset = bitsToDouble(r.u64());
if (reviveTruncatedTail(r, enteredOk)) return;
if (std::isfinite(rate)) p.play.playRate = engine::clampStretchRate(rate);
// The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and
// the velocity->pitch curve speak (play_params.h), reached directly rather than through the
// deck's alias of it.
if (std::isfinite(offset) && offset >= -kVelocityPitchRangeSemitones &&
offset <= kVelocityPitchRangeSemitones) {
p.play.pitchOffsetSemitones = offset;
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
@@ -475,6 +528,11 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, static_cast<std::uint32_t>(
static_cast<std::int32_t>(p.bakeHold.quarterExponent())));
out.push_back(static_cast<std::uint8_t>(p.bakeHold.modifier()));
// v15: the master-bus limiter enable.
out.push_back(p.limiterEnabled ? 1 : 0);
// v16: the playback rate (a ratio) and the baseline pitch offset (semitones), both rate-free.
putLE(out, doubleToBits(pp.playRate));
putLE(out, doubleToBits(pp.pitchOffsetSemitones));
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -529,6 +587,8 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readHardFlags(r, p.play.pitchVelocityCurve);
}
if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p);
if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p);
if (pv >= kParamsRateVersion) readRateAndPitchOffset(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+1 -1
View File
@@ -5,7 +5,7 @@
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
// serialize/deserializeComponentState; nothing outside the codec calls these.
//
// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its
// The format ladder (payload v1..v16) is documented in component_state_io.h, which stays its
// one home. EVERY wire format is FROZEN.
#include <cstdint>
+17
View File
@@ -5,12 +5,25 @@
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
namespace reasampler::instrument::map {
using instrument::engine::VelocityCurve;
// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole
// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage
// time through it, so a stage time can never land on a different frame depending on the writer.
// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0.
inline std::int64_t secondsToFrames(double seconds, double sampleRate) {
if (!(sampleRate > 0.0)) return 0;
double f = seconds * sampleRate;
if (!(f > 0.0)) return 0; // also catches NaN
return static_cast<std::int64_t>(f + 0.5);
}
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
@@ -74,6 +87,10 @@ struct PlaySeconds {
TriggerParams trigger; // Trigger play span (%-length)
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
// Rate and the baseline pitch offset are both rate-FREE (a ratio and a semitone count), so
// they carry through resolvePlay untouched; play_params.h owns what each one means.
double playRate = 1.0;
double pitchOffsetSemitones = 0.0;
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
+21 -5
View File
@@ -3,6 +3,8 @@
#include "core/instrument/map/sample_map.h"
#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period
#include <algorithm> // std::remove_if
#include <cassert> // assert
#include <utility> // std::move
@@ -102,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
}
}
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
@@ -207,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// carries through untouched, already a fraction.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
AhdParams a;
@@ -235,6 +239,8 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.trigger = stored.trigger; // fraction, unchanged
out.trigAhd = resolveAhd(stored.trigAhd);
out.pitchEngine = stored.pitchEngine;
out.playRate = stored.playRate; // a ratio, rate-free
out.pitchOffsetSemitones = stored.pitchOffsetSemitones; // semitones, rate-free
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
@@ -325,6 +331,16 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(resolved.play, data.sampleRate);
// The one place Preserve's source period is computed: the load, off the audio thread.
// Channel 0 only — a stereo pair's two channels share a fundamental, and the splice
// schedule is linked across them anyway. The span is the sustain loop where one is long
// enough (periodAnalysisSpan owns that rule) — every input to it commits through a full
// reload, so the cache is re-derived whenever the span it was chosen from moves.
const instrument::engine::AnalysisSpan span = instrument::engine::periodAnalysisSpan(
data.frames.size(), data.loop.start, data.loop.end, data.loop.hasLoop, data.sampleRate);
data.sourcePeriodFrames =
instrument::engine::detectPeriod(data.frames, data.sampleRate, span.from, span.count)
.frames;
return data;
}
+14 -2
View File
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// True when two refs would build the same SampleData: path plus every intrinsic
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
// what that sample was decoded from; comparing the fields at the call site instead would go
// stale the first time this struct gains one.
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
@@ -173,7 +180,7 @@ struct InstrumentParams {
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// engines.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
@@ -195,6 +202,11 @@ struct InstrumentParams {
// (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating
// the field lifts to it, and no other bake changes.
note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight);
// The master-bus limiter's single enable. It sits OUTSIDE PlaySeconds deliberately: it is
// a post-voice-mixer concern the shell applies to the summed output, never a voice
// parameter, so it must not ride into the live block or the SampleData build. Default off
// — a blob predating the field lifts to bypassed and sounds identical.
bool limiterEnabled = false;
};
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
@@ -203,7 +215,7 @@ struct InstrumentParams {
struct ResolvedCapture {
std::string relativePath; // project-relative; the shell resolves + decodes it
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
+120
View File
@@ -0,0 +1,120 @@
# src/core/instrument/param — the VST3 parameter surface's pure half
## Scope
What the instrument tells a VST3 host about its automatable parameters, with no VST3 type
anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the
plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), the one formatter per
unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge
decision. The VST3 shell (`shell/instrument/instrument_params`) adapts these onto
`Steinberg::Vst::Parameter`; it decides nothing.
A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the
parameter list is a function of `deckParamCommit` and the value binding, never the reverse.
**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
whose control has no home would no-op silently in both directions on the host path with nothing
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.
## Invariants
### The id table is FOREVER-FROZEN
`param_id.h`'s header states the rule in full and is its one home. It sits on the same footing
as the extension's `"STABLE_FOREVER_STRING"` command ids, the two VST3 class UIDs
(`core/wire/reasampler_uid.h`) and the params-payload field order
(`map/component_state_io.h`) — the fourth member of that family, not a new kind of rule.
**The table carries every assigned number, including numbers not issued today.** Membership of
the parameter list is `isExposed`'s answer, not the table's. A row whose control is currently
`Reload`-tier keeps its number reserved: the day that control gains a live path it is exposed
under the number already written beside it, and no other id moves. That is what the
block-and-step scheme buys, and it is why a refusal to promote a control is cheap.
### The list follows the predicate; the predicate is never bent to fill the list
A control is an exposed parameter **iff** `deckParamCommit` classifies it `Live` or
`NoteOnLatched`. There is no second membership table and no per-control exception. Adding a
parameter means giving a control a live path in `deck_groups`, at which point it qualifies by
the same rule that excluded it.
### `toPlain` is a READ-side mapping and changes no stored value
Reporting Hz / Q / drive depth for the filter's four means **calling** `filter_params`' frozen
laws, never replacing them: those four persist as normalized doubles in payload v9, so their
laws are already wire-frozen. The same holds for `master_gain`'s dB sweep and `curve_law`'s
exponent travel. Every law here is called; none is restated.
### ONE formatter per unit category, two callers
`param_format` returns the DIGITS of a plain value. The editor's knob label renders those digits
plus its own static chrome (the unit suffix, a curve dial's `^`); the host receives the same
digits from `getParamStringByValue` and the unit string from `ParameterInfo::units`. There is no
second implementation on either side — that is why `formatEnvTimeMs` and `formatMasterGainLabel`
no longer exist.
## Modules
- `param_id` — the frozen `ParamId` constants, the `ParamRow` table (id, `DeckParam`, `IUnitInfo`
unit, title, shortTitle) in ascending id, `isExposed`, and the derived `exposedParams()`.
Ascending id IS the presentation order, so identity order and presentation order agree by
construction rather than by maintenance.
- `param_units``UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
pair, and the defaults read off a default-constructed `PlaySeconds`.
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`.
- `param_live` — a host parameter write, BOTH sides of the model/audio split: `applyLiveParam`
patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue
the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay`
allocates), and `writeHostParam` lands the same write in the stored parameter set. One value
map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is
pinned by an exhaustive equivalence test between them over every exposed control. The routing
switch carries **no `default:`** — a control promoted into the list without a route fails to
compile, which the call site's discarded return value would otherwise hide.
- `param_merge` — the audio thread's block-boundary merge DECISION, with no atomic and no host
type in it: which held automation points still outrank the model, which the model has caught up
on and are released, and whether an arriving point moves anything at all. It is the testable
half of the AUTHORITY MODEL stated in `shell/instrument/CLAUDE.md`, and the reason both of that
model's failure modes now have a test rather than a reviewer.
## Gotchas
- **`defaultNormalized` is COMPUTED, never a literal.** It is `toNormalized(defaultPlain)` for
every tapered control, so a host's reset-to-default and the editor's double-click land on the
same value. The filter's four are the one exception and for the opposite reason: their stored
value already IS the normalized one, so their default normalized value is that double verbatim
and no taper participates in the reset path at all.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
- **Both the host's read (`toPlain`) and write (`hostStoredFromNorm`) paths for a curve exponent
skip `curve_law`'s knob detent, and that is deliberate** (Daniel, 2026-08-02: continuous ranges
stay continuous at the host boundary). The detent is a DRAG affordance only — a drag grid
delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
step snaps to it — and a host lane has no grid. `curveFromKnobNorm` already answers exactly
`1.0` at norm `0.5`, so skipping the detent costs nothing in reachability from the host. The
dial-drag path (`ui::storedFromNorm`/its snap) is the one place the detented map still applies,
because that is where the snap earns its place. `test_param_live`'s
`testTheHostSkipsTheCurveDetentAndNothingElse` pins the write half; `test_param_format`'s
`testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor` pins the read half.
- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared 60…+24 range
on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The
editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the
`Decibels` + non-finite test) — "-inf" rather than "-infdB", because there is no decibel value
there. The host has no such hook and will render `ParameterInfo::units` beside it, so this is a
deliberate ONE-VALUE break in the "editor digits + chrome == host digits + units" invariant
stated above.
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
three are held to digit-for-digit string equality like everything else. Morph's path is
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
are genuinely different inputs — worth one integer percent at a value landing on a display
rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
value rather than to the string.
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
model took, so the host never holds the rejected value — the sweep skips the clamped steps for
that reason rather than loosening its comparison.
+38
View File
@@ -0,0 +1,38 @@
# The frozen id table and the derived exposed set. Links deck_groups alone: the exposed set IS
# deckParamCommit's answer, and identity needs nothing else.
reasampler_pure_library(param_id SOURCES param_id.cpp LINK PUBLIC deck_groups)
reasampler_test(param_id LINK param_id)
# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field
# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the
# filter's four and the gain report through, CALLED rather than restated. filter_params rather
# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the
# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer
# of param_format which root CLAUDE.md's bake invariant forbids.
reasampler_pure_library(param_units
SOURCES param_units.cpp
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
# sample_map for the test alone: the host-vs-editor default agreement reads the two instance
# scalars where they LIVE, and one of them is a field of InstrumentParams.
reasampler_test(param_units LINK param_units param_id sample_map)
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
# lives, on InstrumentParams.
reasampler_test(param_format LINK param_format param_id sample_map)
# The host write, both sides of the model/audio split: the live block patched in place and the
# stored parameter set written, through one value map (param_units'). No engine the block is a
# value, not a thing the voice owns.
reasampler_pure_library(param_live
SOURCES param_live.cpp
LINK PUBLIC deck_values live_params param_units)
# sample_map for the test alone: the equivalence assertion drives the MODEL path
# (writeHostParam -> resolvePlay -> foldLive) as its reference.
reasampler_test(param_live LINK param_live param_id sample_map)
# The block-boundary merge decision the automation hold's authority lifetime, with no atomic
# and no host type in it.
reasampler_pure_library(param_merge SOURCES param_merge.cpp LINK PUBLIC param_live)
reasampler_test(param_merge LINK param_merge param_id param_units sample_map)
@@ -0,0 +1,76 @@
// param_format.cpp — see param_format.h.
#include "core/instrument/param/param_format.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <limits>
namespace reasampler::instrument::param {
void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len) {
if (!buf || len == 0) return;
switch (kind) {
case UnitKind::Time:
// Never switches to seconds, so the ceiling reads 10000 and not 10 — units is one
// static string per parameter and cannot change with magnitude. Sub-10 ms keeps a
// decimal so a short attack is not rounded to a bare "0".
std::snprintf(buf, len, plain < 10.0 ? "%.1f" : "%.0f", plain);
return;
case UnitKind::Semitones:
std::snprintf(buf, len, "%+.1f", plain);
return;
case UnitKind::PercentUnipolar:
case UnitKind::PercentKeyTrack:
std::snprintf(buf, len, "%.0f", plain);
return;
case UnitKind::PercentBipolar:
std::snprintf(buf, len, "%+.0f", plain);
return;
case UnitKind::PercentRate:
// One decimal, not integer percent: the snap grid is whole semitones and those do
// not land on integer percent (+1 st = 105.946 %), so an integer display would print
// a snapped position as a value the snap cannot produce.
std::snprintf(buf, len, "%.1f", plain);
return;
case UnitKind::Decibels:
if (!std::isfinite(plain)) { std::snprintf(buf, len, "-inf"); return; }
std::snprintf(buf, len, "%+.1f", plain);
return;
case UnitKind::Hertz:
// The "k" abbreviation is RETIRED: units is one static string per parameter, so a
// magnitude-switching unit is not expressible, and keeping "12.8k" in the editor
// alone would be exactly the host/editor divergence one formatter exists to forbid.
std::snprintf(buf, len, "%.0f", plain);
return;
case UnitKind::Dimensionless:
std::snprintf(buf, len, "%.2f", plain);
return;
}
buf[0] = '\0';
}
void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len) {
formatPlain(unitKindFor(deck), plain, buf, len);
}
bool parsePlain(UnitKind kind, const char* text, double& plain) {
if (!text) return false;
if (kind == UnitKind::Decibels) {
// The one non-numeric string any formatter emits, so the one the parser must recognise.
for (const char* p = text; *p; ++p) {
if (*p == 'i' && p[1] == 'n' && p[2] == 'f') {
plain = -std::numeric_limits<double>::infinity();
return true;
}
}
}
char* end = nullptr;
const double value = std::strtod(text, &end);
if (end == text) return false;
plain = value;
return true;
}
} // namespace reasampler::instrument::param
+26
View File
@@ -0,0 +1,26 @@
// param_format.h — ONE formatter per unit category, and the editor and the host are both its
// callers. It returns the DIGITS of a plain value: no embedded unit, no magnitude-switched unit,
// no width-conditional abbreviation. The editor's knob label adds its own static chrome (the
// unit suffix, a curve dial's "^"); the host receives these digits from getParamStringByValue and
// the unit from ParameterInfo::units. There is no second implementation on either side — the two
// surfaces disagreeing about what a value reads as is a defect class this closes structurally.
#pragma once
#include <cstddef>
#include "core/instrument/param/param_units.h"
namespace reasampler::instrument::param {
// Writes at most `len` bytes including the terminator.
void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len);
// The digits a control reads as at `plain`. Convenience over formatPlain for the common case.
void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len);
// Digits -> plain, for getParamValueByString. False when the text carries no number; a trailing
// unit suffix is tolerated, since a user retyping a displayed value keeps it.
bool parsePlain(UnitKind kind, const char* text, double& plain);
} // namespace reasampler::instrument::param
+105
View File
@@ -0,0 +1,105 @@
// param_id.cpp — see param_id.h. The table is written out rather than derived: a derived id is
// a function of something else, and every such input then has to never change. A literal table
// makes the freeze visible AT THE POINT OF CHANGE — it cannot be renumbered by accident, because
// renumbering it means editing the numbers.
#include "core/instrument/param/param_id.h"
namespace reasampler::instrument::param {
namespace {
// Within a block the order is the group's own semantic order — envelope stages in temporal
// order, filter cells in solve order — seeded ONCE here and never re-seeded from cellIds. A
// group's cell order is exactly as mobile as the deck's row order is; this sequence is a
// property of THIS table, which is what lets DeckParam keep its "runtime-only, free to change"
// licence.
const std::vector<ParamRow>& table() {
static const std::vector<ParamRow> kTable = {
{kParamKeyTrackPitch, DeckParam::kKeyTrack, kUnitPitch, "Key Track", "KeyTrk"},
{kParamRate, DeckParam::kRate, kUnitPitch, "Playback Rate", "Rate"},
{kParamPitchOffset, DeckParam::kPitch, kUnitPitch, "Pitch Offset", "Pitch"},
{kParamPitchEnvAttack, DeckParam::kPitchEnvAttack, kUnitPitchEnv, "Pitch Env Attack", "PEnvA"},
{kParamPitchEnvAttackCurve, DeckParam::kPitchEnvAttackCurve, kUnitPitchEnv, "Pitch Env Attack Curve", "PEnvAC"},
{kParamPitchEnvHold, DeckParam::kPitchEnvHold, kUnitPitchEnv, "Pitch Env Hold", "PEnvH"},
{kParamPitchEnvDecay, DeckParam::kPitchEnvDecay, kUnitPitchEnv, "Pitch Env Decay", "PEnvD"},
{kParamPitchEnvDecayCurve, DeckParam::kPitchEnvDecayCurve, kUnitPitchEnv, "Pitch Env Decay Curve", "PEnvDC"},
{kParamPitchEnvDepth, DeckParam::kPitchEnvDepth, kUnitPitchEnv, "Pitch Env Depth", "PEnvDp"},
{kParamFilterMorph, DeckParam::kFilterMorph, kUnitFilter, "Filter Morph", "Morph"},
{kParamFilterCutoff, DeckParam::kFilterCutoff, kUnitFilter, "Filter Cutoff", "Cutoff"},
{kParamFilterQ, DeckParam::kFilterQ, kUnitFilter, "Filter Q", "Q"},
{kParamFilterDrive, DeckParam::kFilterDrive, kUnitFilter, "Filter Drive", "Drive"},
{kParamFilterModAmount, DeckParam::kFilterModAmt, kUnitFilter, "Filter Env Amount", "FEnvAmt"},
{kParamFilterVelAmount, DeckParam::kFilterVel, kUnitFilter, "Filter Vel Amount", "FVelAmt"},
{kParamKeyTrackFilter, DeckParam::kFilterKeyTrack, kUnitFilter, "Filter Key Track", "FKeyTrk"},
{kParamFilterEnvAttack, DeckParam::kFilterEnvAttack, kUnitFilterEnv, "Filter Env Attack", "FEnvA"},
{kParamFilterEnvAttackCurve, DeckParam::kFilterEnvAttackCurve, kUnitFilterEnv, "Filter Env Attack Curve", "FEnvAC"},
{kParamFilterEnvHold, DeckParam::kFilterEnvHold, kUnitFilterEnv, "Filter Env Hold", "FEnvH"},
{kParamFilterEnvDecay, DeckParam::kFilterEnvDecay, kUnitFilterEnv, "Filter Env Decay", "FEnvD"},
{kParamFilterEnvDecayCurve, DeckParam::kFilterEnvDecayCurve, kUnitFilterEnv, "Filter Env Decay Curve", "FEnvDC"},
{kParamFilterEnvSustain, DeckParam::kFilterEnvSustain, kUnitFilterEnv, "Filter Env Sustain", "FEnvS"},
{kParamFilterEnvRelease, DeckParam::kFilterEnvRelease, kUnitFilterEnv, "Filter Env Release", "FEnvR"},
{kParamFilterEnvReleaseCurve, DeckParam::kFilterEnvReleaseCurve, kUnitFilterEnv, "Filter Env Release Curve", "FEnvRC"},
{kParamFilterTrigAttack, DeckParam::kFilterTrigAttack, kUnitFilterEnv, "Filter Trig Attack", "FTrgA"},
{kParamFilterTrigAttackCurve, DeckParam::kFilterTrigAttackCurve, kUnitFilterEnv, "Filter Trig Attack Curve", "FTrgAC"},
{kParamFilterTrigHold, DeckParam::kFilterTrigHold, kUnitFilterEnv, "Filter Trig Hold", "FTrgH"},
{kParamFilterTrigDecay, DeckParam::kFilterTrigDecay, kUnitFilterEnv, "Filter Trig Decay", "FTrgD"},
{kParamFilterTrigDecayCurve, DeckParam::kFilterTrigDecayCurve, kUnitFilterEnv, "Filter Trig Decay Curve", "FTrgDC"},
{kParamAmpAttack, DeckParam::kAttack, kUnitAmp, "Amp Attack", "AmpA"},
{kParamAmpAttackCurve, DeckParam::kAttackCurve, kUnitAmp, "Amp Attack Curve", "AmpAC"},
{kParamAmpHold, DeckParam::kHold, kUnitAmp, "Amp Hold", "AmpH"},
{kParamAmpDecay, DeckParam::kDecay, kUnitAmp, "Amp Decay", "AmpD"},
{kParamAmpDecayCurve, DeckParam::kDecayCurve, kUnitAmp, "Amp Decay Curve", "AmpDC"},
{kParamAmpSustain, DeckParam::kSustain, kUnitAmp, "Amp Sustain", "AmpS"},
{kParamAmpRelease, DeckParam::kRelease, kUnitAmp, "Amp Release", "AmpR"},
{kParamAmpReleaseCurve, DeckParam::kReleaseCurve, kUnitAmp, "Amp Release Curve", "AmpRC"},
{kParamTriggerLength, DeckParam::kTrigLength, kUnitAmp, "Trigger Length", "TrgLen"},
{kParamAmpTrigAttack, DeckParam::kTrigAttack, kUnitAmp, "Amp Trig Attack", "ATrgA"},
{kParamAmpTrigAttackCurve, DeckParam::kTrigAttackCurve, kUnitAmp, "Amp Trig Attack Curve", "ATrgAC"},
{kParamAmpTrigHold, DeckParam::kTrigHold, kUnitAmp, "Amp Trig Hold", "ATrgH"},
{kParamAmpTrigDecay, DeckParam::kTrigDecay, kUnitAmp, "Amp Trig Decay", "ATrgD"},
{kParamAmpTrigDecayCurve, DeckParam::kTrigDecayCurve, kUnitAmp, "Amp Trig Decay Curve", "ATrgDC"},
{kParamMasterGain, DeckParam::kMasterGain, kUnitMaster, "Master Gain", "Gain"},
};
return kTable;
}
} // namespace
const std::vector<ParamRow>& paramTable() { return table(); }
bool isExposed(DeckParam deck) {
return ui::deckParamCommit(deck) != ui::LiveCommit::Reload;
}
const std::vector<ParamRow>& exposedParams() {
static const std::vector<ParamRow> kExposed = [] {
std::vector<ParamRow> rows;
for (const ParamRow& row : table()) {
if (isExposed(row.deck)) rows.push_back(row);
}
return rows;
}();
return kExposed;
}
const ParamRow* exposedRowFor(ParamId id) {
for (const ParamRow& row : exposedParams()) {
if (row.id == id) return &row;
}
return nullptr;
}
ParamId paramIdFor(DeckParam deck) {
for (const ParamRow& row : table()) {
if (row.deck == deck) return row.id;
}
return 0;
}
} // namespace reasampler::instrument::param
+139
View File
@@ -0,0 +1,139 @@
// param_id.h — the VST3 parameter identity space: the frozen id table, its DeckParam binding,
// the deck-group units, and the exposed set DERIVED from the commit predicate. Pure: no VST3
// type appears here, so the whole contract is provable without a host.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/deck_groups.h" // DeckParam + deckParamCommit (the predicate)
namespace reasampler::instrument::param {
using ui::DeckParam;
// A host records this number into automation lanes inside project files this repo does not own
// and cannot migrate.
//
// THE PARAMETER-ID TABLE IS FOREVER-FROZEN, on the same footing as the extension's
// "STABLE_FOREVER_STRING" command ids, the two VST3 class UIDs (core/wire/reasampler_uid.h) and
// the params-payload field order (map/component_state_io.h):
// - No id is ever reassigned, reused or re-pointed. A control whose meaning genuinely changes
// takes a NEW id; the old one is marked dead here and never re-issued.
// - No exposed parameter's normalization ever changes — not its taper, not either range
// endpoint, not its stepCount. The normalization IS the meaning of every recorded point.
// - A parameter's meaning never depends on a mode. The Gate-face and Trigger-face stage times
// are separate stored fields and take separate ids.
// - A new control takes the next free slot inside its own group's block, never the next number
// at the end of the table.
// Display strings, titles and precision are NOT frozen — they are what a user reads, not what a
// lane stores.
using ParamId = std::uint32_t;
// Blocks of 100 per deck group in SIGNAL-FLOW order, steps of 10 within a block, a curve dial at
// its outer knob's id + 1. Blocks start at 1000 so the first legitimate id is not also the most
// likely bug value (a default-initialised ParamId). Nine free slots between neighbours put a
// control added later numerically beside its siblings instead of at the end of the table.
// 1500-1599 (VELOCITY) and 1600-1699 (VOICE) are RESERVED and empty — a control either group
// ever gains lands in its own range rather than in whatever range happened to be free.
// A block names the signal-flow STAGE, not the deck group a control happens to be drawn in, and
// the two have already diverged once: kParamFilterModAmount (1240) stays in the FILTER block
// while its knob moved to the FILTER ENVELOPE deck. Ids key on the control, so a redraw never
// renumbers — the freeze is what makes annotating this the fix rather than moving the row.
enum : ParamId {
kParamKeyTrackPitch = 1000,
kParamRate = 1010,
kParamPitchOffset = 1020,
kParamPitchEnvAttack = 1100,
kParamPitchEnvAttackCurve= 1101,
kParamPitchEnvHold = 1110,
kParamPitchEnvDecay = 1120,
kParamPitchEnvDecayCurve = 1121,
kParamPitchEnvDepth = 1130,
kParamFilterMorph = 1200,
kParamFilterCutoff = 1210,
kParamFilterQ = 1220,
kParamFilterDrive = 1230,
kParamFilterModAmount = 1240,
kParamFilterVelAmount = 1250,
kParamKeyTrackFilter = 1260,
kParamFilterEnvAttack = 1300,
kParamFilterEnvAttackCurve = 1301,
kParamFilterEnvHold = 1310,
kParamFilterEnvDecay = 1320,
kParamFilterEnvDecayCurve = 1321,
kParamFilterEnvSustain = 1330,
kParamFilterEnvRelease = 1340,
kParamFilterEnvReleaseCurve = 1341,
kParamFilterTrigAttack = 1350,
kParamFilterTrigAttackCurve = 1351,
kParamFilterTrigHold = 1360,
kParamFilterTrigDecay = 1370,
kParamFilterTrigDecayCurve = 1371,
kParamAmpAttack = 1400,
kParamAmpAttackCurve = 1401,
kParamAmpHold = 1410,
kParamAmpDecay = 1420,
kParamAmpDecayCurve = 1421,
kParamAmpSustain = 1430,
kParamAmpRelease = 1440,
kParamAmpReleaseCurve = 1441,
kParamTriggerLength = 1450,
kParamAmpTrigAttack = 1460,
kParamAmpTrigAttackCurve = 1461,
kParamAmpTrigHold = 1470,
kParamAmpTrigDecay = 1480,
kParamAmpTrigDecayCurve = 1481,
kParamMasterGain = 1700,
};
// IUnitInfo units, one per deck group that carries an exposed parameter. 0 is the SDK's root
// unit, so these start at 1. Softer than the id freeze but user-facing and cached by some hosts.
enum : std::int32_t {
kUnitRoot = 0,
kUnitPitch = 1,
kUnitPitchEnv = 2,
kUnitFilter = 3,
kUnitFilterEnv = 4,
kUnitAmp = 5,
kUnitMaster = 6,
};
struct ParamRow {
ParamId id;
DeckParam deck;
std::int32_t unit;
const char* title; // survives truncation
const char* shortTitle; // distinct, for a narrow host column
};
// The WHOLE frozen assignment, in ascending id — which is also the presentation order, so
// identity order and presentation order agree by construction rather than by maintenance.
// Membership of the parameter list is NOT decided here: a row is issued to the host only if
// isExposed() says so. A row whose control is not exposed today keeps its number reserved for
// the day that control gains a live path, which is what the block-and-step scheme buys.
const std::vector<ParamRow>& paramTable();
// A control is an exposed VST3 parameter IF AND ONLY IF its commit class is Live or
// NoteOnLatched. Derived from deckParamCommit, never hand-maintained: the list follows the
// predicate, and the predicate is never bent to fill the list.
bool isExposed(DeckParam deck);
// paramTable() filtered by isExposed, still in ascending id. This is exactly what the host is
// told, in the order it is told.
const std::vector<ParamRow>& exposedParams();
// The row for an id, or null when the id is unknown or its control is not exposed.
const ParamRow* exposedRowFor(ParamId id);
// The id a control is numbered as, or 0 when the control has no row at all. Answers for
// unexposed rows too — the number is a property of the table, not of today's membership.
ParamId paramIdFor(DeckParam deck);
} // namespace reasampler::instrument::param
+126
View File
@@ -0,0 +1,126 @@
// param_live.cpp — see param_live.h. ONE exhaustive routing switch and one shared value map;
// every law is called, none is restated.
#include "core/instrument/param/param_live.h"
#include <cstdint>
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
#include "core/instrument/param/param_units.h" // hostStoredFromNorm (the ONE host value map)
#include "core/instrument/ui/deck_values.h" // the field resolvers setDeckParam writes through
namespace reasampler::instrument::param {
using engine::LiveValues;
bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) {
const double stored = hostStoredFromNorm(deck, normalized);
const auto frames = [&](std::int64_t& dst) {
dst = map::secondsToFrames(stored, static_cast<double>(sampleRate));
return true;
};
const auto position = [&](float& dst) { dst = static_cast<float>(stored); return true; };
const auto value = [&](double& dst) { dst = stored; return true; };
// NO `default:` — see the header. A promotion that forgets this file is a compile error.
switch (deck) {
// The fourteen stage times: stored seconds resolved at the BUILT rate.
case DeckParam::kAttack: return frames(block.adsr.attackFrames);
case DeckParam::kHold: return frames(block.adsr.holdFrames);
case DeckParam::kDecay: return frames(block.adsr.decayFrames);
case DeckParam::kRelease: return frames(block.adsr.releaseFrames);
case DeckParam::kTrigAttack: return frames(block.ampAhd.attackFrames);
case DeckParam::kTrigDecay: return frames(block.ampAhd.decayFrames);
case DeckParam::kPitchEnvAttack: return frames(block.pitchEnv.shape.attackFrames);
case DeckParam::kPitchEnvDecay: return frames(block.pitchEnv.shape.decayFrames);
case DeckParam::kFilterEnvAttack: return frames(block.filterEnv.attackFrames);
case DeckParam::kFilterEnvHold: return frames(block.filterEnv.holdFrames);
case DeckParam::kFilterEnvDecay: return frames(block.filterEnv.decayFrames);
case DeckParam::kFilterEnvRelease: return frames(block.filterEnv.releaseFrames);
case DeckParam::kFilterTrigAttack: return frames(block.filterAhd.attackFrames);
case DeckParam::kFilterTrigDecay: return frames(block.filterAhd.decayFrames);
// The filter's four, which store their normalized position as float exactly as the
// parameter set stores it.
case DeckParam::kFilterMorph: return position(block.filterSettings.morphNorm);
case DeckParam::kFilterCutoff: return position(block.filterSettings.cutoffNorm);
case DeckParam::kFilterQ: return position(block.filterSettings.resonanceNorm);
case DeckParam::kFilterDrive: return position(block.filterSettings.driveNorm);
// Everything the block carries verbatim as a double.
case DeckParam::kSustain: return value(block.adsr.sustainLevel);
case DeckParam::kAttackCurve: return value(block.adsr.attackCurve);
case DeckParam::kDecayCurve: return value(block.adsr.decayCurve);
case DeckParam::kReleaseCurve: return value(block.adsr.releaseCurve);
case DeckParam::kTrigHold: return value(block.ampAhd.holdFraction);
case DeckParam::kTrigAttackCurve: return value(block.ampAhd.attackCurve);
case DeckParam::kTrigDecayCurve: return value(block.ampAhd.decayCurve);
case DeckParam::kPitchEnvHold: return value(block.pitchEnv.shape.holdFraction);
case DeckParam::kPitchEnvAttackCurve: return value(block.pitchEnv.shape.attackCurve);
case DeckParam::kPitchEnvDecayCurve: return value(block.pitchEnv.shape.decayCurve);
case DeckParam::kPitchEnvDepth: return value(block.pitchEnv.peakSemitones);
case DeckParam::kFilterEnvSustain: return value(block.filterEnv.sustainLevel);
case DeckParam::kFilterEnvAttackCurve: return value(block.filterEnv.attackCurve);
case DeckParam::kFilterEnvDecayCurve: return value(block.filterEnv.decayCurve);
case DeckParam::kFilterEnvReleaseCurve: return value(block.filterEnv.releaseCurve);
case DeckParam::kFilterTrigHold: return value(block.filterAhd.holdFraction);
case DeckParam::kFilterTrigAttackCurve: return value(block.filterAhd.attackCurve);
case DeckParam::kFilterTrigDecayCurve: return value(block.filterAhd.decayCurve);
case DeckParam::kFilterModAmt: return value(block.filterModAmount);
case DeckParam::kFilterVel: return value(block.filterVelAmount);
case DeckParam::kFilterKeyTrack: return value(block.filterKeyTrack);
case DeckParam::kRate: return value(block.playRate);
case DeckParam::kPitch: return value(block.pitchOffsetSemitones);
case DeckParam::kKeyTrack: return value(block.keyTrack);
// The one control the block does not carry verbatim: what it publishes is the
// SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for the
// same reason the knob is inert in the editor.
case DeckParam::kTrigLength:
if (!block.splineActive) block.lengthFraction = stored;
return true;
// Not carried. Master gain reaches the audio beside the block, as the processor's own
// atomic; the rest are toggles, radios, curve-popup cells and the deck's processor-side
// controls — all Reload- or rebuild-tier, so none of them is an exposed parameter.
case DeckParam::kMasterGain:
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kPitchEnvEnable:
case DeckParam::kFilterEnable:
case DeckParam::kFilterLaw:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
case DeckParam::kFilterVelCurve:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kLimiterEnable:
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount:
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized) {
const double stored = hostStoredFromNorm(deck, normalized);
if (float* f = ui::deckFloatField(deck, play)) {
*f = static_cast<float>(stored);
return true;
}
if (double* d = ui::deckDoubleField(deck, play)) {
*d = stored;
return true;
}
return false;
}
} // namespace reasampler::instrument::param
+41
View File
@@ -0,0 +1,41 @@
// param_live.h — a host parameter write landed on BOTH sides of the model/audio split: into the
// live block in place (RT-safe, for `IParameterChanges`, which the SDK delivers on the audio
// thread where the model path cannot run — `resolvePlay` allocates), and into the stored
// parameter set. One norm -> stored map serves both, so they cannot disagree.
#pragma once
#include "core/instrument/engine/live_params.h"
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the model-side write target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
namespace reasampler::instrument::param {
using ui::DeckParam;
// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental
// beyond the taper's own. Returns false for a control this block does not carry — master gain,
// which reaches the audio as the processor's own atomic, and anything unexposed.
//
// The value laws are NOT restated here: `hostStoredFromNorm` is the same norm -> stored map the
// model-side write below takes, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
// IS new is the routing — which member of the block a control names — and its switch carries no
// `default:`, so a control promoted into the parameter list without a route here fails to COMPILE
// rather than dropping its automation silently at a call site that discards the answer.
//
// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_),
// so a patched stage time lands on exactly the frames the build would have resolved.
bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate);
// The MODEL-side peer: the same host write, landed in the stored parameter set instead. Sharing
// `hostStoredFromNorm` and the field resolvers with the patch above is what makes the equivalence
// test's claim — patch == fold-after-write — a property of one map rather than of two that agree.
// False for a control PlaySeconds does not carry: the two instance scalars (master gain, pitch
// key-track) are written where they live, by the shell.
//
// No `enforceGateUnavailableWhileDrawn` here, unlike `ui::setDeckParam`: every control that can
// flip `splineActive` is a toggle, every toggle is Reload-tier, and no Reload-tier control is
// exposed — so nothing reachable from a host write can open that hole.
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized);
} // namespace reasampler::instrument::param
+25
View File
@@ -0,0 +1,25 @@
// param_merge.cpp — see param_merge.h.
#include "core/instrument/param/param_merge.h"
#include "core/instrument/param/param_live.h"
namespace reasampler::instrument::param {
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
int sampleRate) {
for (std::size_t i = 0; i < count; ++i) {
AutomationSlot& slot = slots[i];
if (!slot.held) continue;
if (slot.folded) {
// Nothing to patch: `block` was read from the model, and the model is what the fold
// wrote this point into. Dropping the hold here is the whole release.
slot.held = false;
slot.folded = false;
continue;
}
applyLiveParam(block, static_cast<DeckParam>(i), slot.norm, sampleRate);
}
}
} // namespace reasampler::instrument::param
+51
View File
@@ -0,0 +1,51 @@
// param_merge.h — the audio thread's block-boundary merge DECISION, with no host type and no
// atomic in it: which held automation points still outrank the model, which the model has caught
// up on and are released, and whether the result is worth republishing. The AUTHORITY MODEL it
// implements is stated in `shell/instrument/CLAUDE.md`; this is its testable half.
#pragma once
#include <cstddef>
#include "core/instrument/engine/live_params.h"
#include "core/instrument/ui/deck_groups.h" // DeckParam (the ordinal space slots are indexed by)
namespace reasampler::instrument::param {
using ui::DeckParam;
// The DeckParam ordinal space. One slot per control, indexed by ordinal, so a lookup is an index
// rather than a search on the audio thread.
inline constexpr std::size_t kDeckParamSlots = static_cast<std::size_t>(DeckParam::kCount);
// One control's automation state as the merge sees it.
struct AutomationSlot {
double norm = 0.0; // the last point this lane delivered
bool held = false; // that point still outranks the model
bool folded = false; // the model has since been rewritten to carry THAT point
};
// Patches every still-held slot over `block`, and RELEASES each slot the model has caught up on.
//
// The release is what BOUNDS a point's authority. A lane outranks a plug-in-side set only while
// it is driving; a value it delivered once, already folded back into the model, outranks nothing.
// Without the release a single automation point would defeat every later state restore, bake
// reset and knob move for the life of the instance — which is the failure this function exists
// to make impossible, and which `test_param_merge` is the test of.
//
// RT-SAFE: no allocation, no lock, no transcendental beyond the tapers' own.
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
int sampleRate);
// Whether a point of `normalized` for a slot in this state actually moves the block. False for a
// point equal to a hold that is still standing — the ordinary read-mode steady state, where a
// host delivers one point per block over a flat lane segment. Republishing there would drive
// `VoiceEngine::applyLiveToActive` over every sounding voice — a `std::pow`, two envelope φ
// re-fits and the filter ramp aims, per voice — for a value that did not move. Once the hold has
// been RELEASED the answer is true again, because some other writer may have moved the model
// since.
inline bool automationPointMoves(const AutomationSlot& slot, double normalized) {
return !slot.held || slot.norm != normalized;
}
} // namespace reasampler::instrument::param
+304
View File
@@ -0,0 +1,304 @@
// param_units.cpp — see param_units.h. Every law here is CALLED, never restated: the stage-time
// and semitone tapers are param_taper's, the curve travel is curve_law's, the filter's four are
// filter_params' own frozen laws, and the dB sweep is master_gain's.
#include "core/instrument/param/param_units.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/master_gain.h"
#include "core/instrument/map/play_seconds.h"
#include "core/instrument/ui/deck_values.h"
#include "core/instrument/ui/param_taper.h"
#include "core/util/clamp01.h"
#include "core/util/curve_law.h"
namespace reasampler::instrument::param {
namespace {
using map::PlaySeconds;
using util::clamp01;
// A whole displayed percent is a different plain full scale per category; these are the two
// non-100 ones, named rather than inlined so the range table and the maps cannot disagree.
constexpr double kPercentFullScale = 100.0;
const double kKeyTrackFullScale = kPercentFullScale * ui::kKeyTrackMax; // 0..200 %
} // namespace
UnitKind unitKindFor(DeckParam deck) {
switch (deck) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigDecay:
return UnitKind::Time;
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return UnitKind::Semitones;
case DeckParam::kSustain:
case DeckParam::kTrigLength:
case DeckParam::kTrigHold:
case DeckParam::kPitchEnvHold:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterMorph:
return UnitKind::PercentUnipolar;
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return UnitKind::PercentKeyTrack;
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return UnitKind::PercentBipolar;
case DeckParam::kRate:
return UnitKind::PercentRate;
case DeckParam::kMasterGain:
return UnitKind::Decibels;
case DeckParam::kFilterCutoff:
return UnitKind::Hertz;
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
// rather than defaulted, and everything with no parameter row at all is listed with
// them: a `default:` here would let a control promoted later inherit Dimensionless
// silently, and an exposed parameter's normalization is frozen on the first shipped
// build — so the wrong answer would be permanent rather than correctable. THIS switch is
// the one that has to be exhaustive; the `default:` arms further down are pre-dispatch
// filters that fall through to it, so they inherit its exhaustiveness rather than
// needing their own.
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kPitchEnvEnable:
case DeckParam::kFilterEnable:
case DeckParam::kFilterLaw:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
case DeckParam::kFilterVelCurve:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kLimiterEnable:
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
return UnitKind::Dimensionless;
// The sentinel, on its own arm: it names no control, so its unit string, plain range and
// toPlain law are all arbitrary. It is here only because the switch is exhaustive, and
// it stays out of the run above so that run reads as a list of real controls.
case DeckParam::kCount:
return UnitKind::Dimensionless;
}
return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning.
}
const char* unitStringFor(DeckParam deck) {
switch (unitKindFor(deck)) {
case UnitKind::Time: return "ms";
case UnitKind::Semitones: return "st";
case UnitKind::PercentUnipolar:
case UnitKind::PercentKeyTrack:
case UnitKind::PercentBipolar:
case UnitKind::PercentRate: return "%";
case UnitKind::Decibels: return "dB";
case UnitKind::Hertz: return "Hz";
case UnitKind::Dimensionless: return "";
}
return "";
}
PlainRange plainRangeFor(DeckParam deck) {
switch (deck) {
case DeckParam::kFilterQ:
return {static_cast<double>(engine::filter::kFilterQMin),
static_cast<double>(engine::filter::kFilterQMax)};
case DeckParam::kFilterDrive:
return {0.0, static_cast<double>(engine::filter::kFilterDriveDepthMax)};
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time: return {0.0, ui::kEnvTimeMaxSeconds * 1000.0};
case UnitKind::Semitones: return {-ui::kPitchDepthMaxSemis, ui::kPitchDepthMaxSemis};
case UnitKind::PercentUnipolar: return {0.0, kPercentFullScale};
case UnitKind::PercentKeyTrack: return {0.0, kKeyTrackFullScale};
case UnitKind::PercentBipolar: return {-kPercentFullScale, kPercentFullScale};
case UnitKind::PercentRate: return {ui::kRateMinRatio * kPercentFullScale,
ui::kRateMaxRatio * kPercentFullScale};
case UnitKind::Decibels: return {engine::kMasterGainMinDb, engine::kMasterGainMaxDb};
case UnitKind::Hertz: return {static_cast<double>(engine::filter::kFilterCutoffMinHz),
static_cast<double>(engine::filter::kFilterCutoffMaxHz)};
case UnitKind::Dimensionless: return {util::kCurveMin, util::kCurveMax};
}
return {};
}
double toPlain(DeckParam deck, double normalized) {
switch (deck) {
case DeckParam::kFilterCutoff:
return static_cast<double>(
engine::filter::filterCutoffHzFromNorm(static_cast<float>(normalized)));
case DeckParam::kFilterQ:
return static_cast<double>(
engine::filter::filterQFromNorm(static_cast<float>(normalized)));
case DeckParam::kFilterDrive:
return static_cast<double>(
engine::filter::filterDriveDepthFromNorm(static_cast<float>(normalized)));
case DeckParam::kMasterGain:
// -inf at norm 0 — true silence, and the one plain value outside the declared range.
return engine::masterGainDbFromNorm(normalized);
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time:
return ui::timeSecondsFromNorm(normalized) * 1000.0;
case UnitKind::Semitones:
return ui::depthSemitonesFromNorm(normalized, ui::kPitchDepthMaxSemis);
case UnitKind::PercentUnipolar:
return clamp01(normalized) * kPercentFullScale;
case UnitKind::PercentKeyTrack:
return clamp01(normalized) * kKeyTrackFullScale;
case UnitKind::PercentBipolar:
return ui::deckBipolarFromNorm(normalized) * kPercentFullScale;
case UnitKind::PercentRate:
return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) *
kPercentFullScale;
case UnitKind::Dimensionless:
// Undetented: a host-facing continuous range stays continuous (Daniel, 2026-08-02) —
// the detent is a drag affordance, not part of the value law. See hostStoredFromNorm.
return util::curveFromKnobNormUndetented(normalized);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above
}
return normalized;
}
double toNormalized(DeckParam deck, double plain) {
switch (deck) {
case DeckParam::kFilterCutoff:
return static_cast<double>(
engine::filter::filterNormFromCutoffHz(static_cast<float>(plain)));
case DeckParam::kFilterQ:
return static_cast<double>(
engine::filter::filterNormFromQ(static_cast<float>(plain)));
case DeckParam::kFilterDrive:
return static_cast<double>(
engine::filter::filterNormFromDriveDepth(static_cast<float>(plain)));
case DeckParam::kMasterGain:
return engine::masterGainNormFromDb(plain);
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time:
return ui::timeNormFromSeconds(plain / 1000.0);
case UnitKind::Semitones:
return ui::depthNormFromSemitones(plain, ui::kPitchDepthMaxSemis);
case UnitKind::PercentUnipolar:
return clamp01(plain / kPercentFullScale);
case UnitKind::PercentKeyTrack:
return clamp01(plain / kKeyTrackFullScale);
case UnitKind::PercentBipolar:
return ui::deckNormFromBipolar(plain / kPercentFullScale);
case UnitKind::PercentRate:
return ui::rateNormFromRatio(plain / kPercentFullScale, ui::kRateMinRatio,
ui::kRateMaxRatio);
case UnitKind::Dimensionless:
return util::knobNormFromCurve(plain);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above
}
return plain;
}
double hostStoredFromNorm(DeckParam deck, double normalized) {
// See the header for why the detent is a drag affordance and not part of the value law.
if (ui::deckParamUnit(deck) == ui::UnitCategory::Exponent) {
return util::curveFromKnobNormUndetented(normalized);
}
return ui::storedFromNorm(deck, normalized);
}
ValueHome valueHomeFor(DeckParam deck) {
PlaySeconds defaults;
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet;
if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) {
return ValueHome::InstanceScalar;
}
return ValueHome::None;
}
bool storesNormalized(DeckParam deck) {
return valueHomeFor(deck) == ValueHome::ParamSetNorm;
}
double defaultPlain(DeckParam deck) {
PlaySeconds defaults;
// The filter's four store the normalized position itself, so their plain default is that
// stored position read THROUGH the law — the law is the display, never the storage.
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return toPlain(deck, static_cast<double>(*stored));
}
// The two instance scalars, whose default is not a field of PlaySeconds.
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
if (deck == DeckParam::kKeyTrack) {
return kKeyTrackDefault * kPercentFullScale;
}
const double* field = ui::deckDoubleField(deck, defaults);
if (!field) return 0.0;
switch (unitKindFor(deck)) {
// Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness
// additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS,
// not in ms. It holds for today's three Time defaults; a new one is a case to re-check.
case UnitKind::Time: return *field * 1000.0; // stored seconds
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
case UnitKind::PercentBipolar: return *field * kPercentFullScale;
case UnitKind::PercentRate: return *field * kPercentFullScale; // stored ratio
case UnitKind::Semitones:
case UnitKind::Dimensionless: return *field; // already the plain unit
case UnitKind::Decibels:
case UnitKind::Hertz: break; // handled above
}
return *field;
}
double defaultNormalized(DeckParam deck) {
PlaySeconds defaults;
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return static_cast<double>(*stored); // verbatim: no taper on the reset path
}
return toNormalized(deck, defaultPlain(deck));
}
} // namespace reasampler::instrument::param
+85
View File
@@ -0,0 +1,85 @@
// param_units.h — the plain-value layer the host reads a parameter through: the unit category,
// the plain range, and the norm <-> plain pair. `toPlain` IS the taper's forward map and
// `toNormalized` its inverse, so the host's normalization, the knob's needle angle and the
// overlay node's position are the SAME function rather than three that agree today.
#pragma once
#include "core/instrument/ui/deck_groups.h" // DeckParam
namespace reasampler::instrument::param {
using ui::DeckParam;
// The eight DISPLAY categories. A category fixes the units string and the digit precision; the
// norm <-> plain LAW is per control, because three of the dimensionless controls (the curve
// exponents, Q, drive) share a display and share no law.
enum class UnitKind {
Time, // ms, 0..10000
Semitones, // st, -24..+24, always signed
PercentUnipolar, // %, 0..100
PercentKeyTrack, // %, 0..200
PercentBipolar, // %, -100..+100, always signed
PercentRate, // %, 50..200, one decimal
Decibels, // dB, -60..+24, always signed; norm 0 reads -inf
Hertz, // Hz, 20..20000
Dimensionless, // no unit, two decimals
};
struct PlainRange {
double min = 0.0;
double max = 1.0;
};
UnitKind unitKindFor(DeckParam deck);
// The units string ParameterInfo carries — "" for the dimensionless category. Carried SEPARATELY
// from the digits, which is the SDK's own convention (RangeParameter::toString prints the number;
// the Parameter constructor takes units as its own argument).
const char* unitStringFor(DeckParam deck);
PlainRange plainRangeFor(DeckParam deck);
// A straight line drawn in a host automation lane is NOT linear in these plain units, and that is
// deliberate: exponential in ms, linear in octaves on cutoff, linear in dB on master gain, a
// linear pitch glide on rate, and slow-near-zero on the two centre-expanded semitone throws. It
// follows from reporting real units over a musically-shaped taper; the remedy for a user who
// wants a literal-units ramp is the host's own curve tools, never a change to the taper.
double toPlain(DeckParam deck, double normalized);
double toNormalized(DeckParam deck, double plain);
// The STORED value a host write of `normalized` lands on — `ui::storedFromNorm` for every
// control except the twelve curve exponents, where the knob law's ±0.01 detent is skipped. That
// detent is a DRAG affordance: a drag grid lands on the identity only by luck, so a band wider
// than one drag step snaps to it. A host lane has no grid and `curveFromKnobNorm` already
// answers exactly 1.0 at norm 0.5, so applying the detent here would not make anything
// reachable — it would flatten a knot-drawn near-neutral exponent to 1.0 on any lane pass.
// BOTH host write paths take this map (the model's `writeHostParam`, the audio thread's
// `applyLiveParam`), which is what keeps them from landing different values in the same block.
double hostStoredFromNorm(DeckParam deck, double normalized);
// WHERE a control's value actually lives. The host's read and write paths branch on this, and
// the exposed set is asserted against it: a control promoted into the list with no home would
// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
enum class ValueHome {
None, // not a scalar control at all — a toggle, a radio, a curve-popup cell
ParamSetNorm, // the filter's four: the stored double IS the normalized position
ParamSet, // every other knob the parameter set carries
InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar
};
ValueHome valueHomeFor(DeckParam deck);
// The filter's four tone controls STORE their normalized position (payload v9), so their default
// normalized value is that stored double verbatim and no taper participates in a host's
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
// replacing them.
bool storesNormalized(DeckParam deck);
// The default, read off a default-constructed PlaySeconds — there is no second table of defaults,
// and no normalized default is ever written as a literal. defaultNormalized is COMPUTED as
// toNormalized(defaultPlain) for every tapered control, which is what makes a host's
// reset-to-default and the editor's double-click land on the same value.
double defaultPlain(DeckParam deck);
double defaultNormalized(DeckParam deck);
} // namespace reasampler::instrument::param
+68 -14
View File
@@ -20,25 +20,38 @@ reasampler_test(capture_browser LINK capture_browser)
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
# header needs it.
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit the meter's bar
# count consumes that answer, so the type is part of this module's surface, not an internal.
# component_geometry is PRIVATE: the overlay area is DERIVED from the draw chain's own column
# count so the two cannot drift, but no type of it appears in this module's header.
reasampler_pure_library(waveform_view
SOURCES waveform_view.cpp
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
# sample_bands is linked directly here because the test exercises the lane metrics that
# waveform_view does not re-export.
reasampler_test(waveform_view LINK waveform_view sample_bands)
LINK PUBLIC editor_geometry peaks sample_bands PRIVATE component_geometry)
# component_geometry is linked for the test too: proving the overlay rides the drawn column
# band takes the real column count, not a copy of it.
reasampler_test(waveform_view LINK waveform_view sample_bands component_geometry)
# The loop enable's state machine. Links loop_span for the park bounds the span the user is
# offered and the span the engine accepts stay one definition.
reasampler_pure_library(loop_marks SOURCES loop_marks.cpp LINK PUBLIC loop_span)
reasampler_test(loop_marks LINK loop_marks)
reasampler_pure_library(browser_scroll
SOURCES browser_scroll.cpp
LINK PUBLIC capture_browser sample_chrome)
reasampler_test(browser_scroll LINK browser_scroll)
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
reasampler_pure_library(param_slider
SOURCES param_slider.cpp
LINK PUBLIC editor_geometry param_taper)
reasampler_test(param_slider LINK param_slider)
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
reasampler_test(envelope_overlay LINK envelope_overlay)
reasampler_pure_library(envelope_overlay
SOURCES envelope_overlay.cpp
LINK PUBLIC editor_geometry curve_law param_taper)
# sample_bands is linked for the test only: the tapered-axis legibility assertion is judged at the
# editor's own floor width, read from the allocator rather than copied as a number.
reasampler_test(envelope_overlay LINK envelope_overlay sample_bands)
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
reasampler_test(envelope_edit LINK envelope_edit)
@@ -46,17 +59,37 @@ reasampler_test(envelope_edit LINK envelope_edit)
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
reasampler_test(knob_deck LINK knob_deck)
# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to
# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC
# for LaneSplit the bar count is the SAME resolved decision the waveform's lane split is.
reasampler_pure_library(master_meter
SOURCES master_meter.cpp
LINK PUBLIC editor_geometry sample_bands meter_ballistics)
# waveform_view is linked for the test only: proving the bar count is not a second rule takes
# the real waveformSurface fold, over channel mode x source channel count.
reasampler_test(master_meter LINK master_meter waveform_view)
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
# master_meter is PRIVATE: MASTER's descriptor reserves the meter column's own kMeterColumnW,
# but nothing in deck_groups.h names a meter type, so the edge stops at this TU.
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
# WHICH descriptors the deck carries, and how they resolve to a layout no window-floor budget
# assertion here, so this target needs neither sample_bands nor master_meter.
reasampler_test(deck_groups LINK deck_groups)
# The width-BUDGET half, split out on the same seam PRIVATE master_meter already draws above:
# the deck-fits-the-floor-window assertion needs the band allocator, and the MASTER-reserve
# identity needs the column width the PRIVATE edge on deck_groups does not re-export.
reasampler_test(deck_groups_measured LINK deck_groups sample_bands master_meter)
# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam
# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure
# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter.
reasampler_test(deck_groups_state LINK deck_groups)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free the staged envelopes touch no curve).
@@ -74,9 +107,11 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
# from knob_deck. Links the header-only play_seconds, NOT sample_map: PlaySeconds is all a deck
# knob edits, and sample_map would drag the bank model and the WAV codec in behind it. Same for
# the filter's MorphLaw an enum, so no filter symbol is linked.
# time_stretch carries Rate's range the stretcher's own measured bounds, aliased here rather
# than restated so the knob's ends and the engine's clamp cannot disagree.
reasampler_pure_library(deck_values
SOURCES deck_values.cpp
LINK PUBLIC deck_groups play_seconds envelope_overlay)
LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain time_stretch)
reasampler_test(deck_values LINK deck_values)
# The bake Hold knob's value domain. Links the ladder alone it computes no geometry, so it
@@ -88,3 +123,22 @@ reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_g
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
# proving that takes a curve of each domain mapped through the one curveBox.
reasampler_test(curve_popup LINK curve_popup velocity_curve)
# The ONE norm<->value taper and modifier vocabulary every variable control shares. Declared
# last, but it sits at the BOTTOM of this directory's dependency order: param_slider,
# envelope_overlay and deck_values all read it which is exactly why it could not stay inside
# deck_values, which sits above envelope_overlay.
reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law)
# envelope_overlay and sample_bands are linked for the test only: the finest-drag-step assertion
# is judged against the envelope node drag at the editor's own floor width (the sharper of the
# taper's two consumers), read from the allocator/overlay rather than copied as a number.
reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands)
# The staged trace, split from envelope_overlay's vertex model. stroke_aa is the trace-point
# vocabulary, filled in place so the shell's scratch buffer is reused rather than a fresh
# vector returned per paint; curve_law arrives through envelope_overlay but is named here
# because this module evaluates the law rather than merely carrying its exponents.
reasampler_pure_library(curve_tessellate
SOURCES curve_tessellate.cpp
LINK PUBLIC envelope_overlay stroke_aa curve_law)
reasampler_test(curve_tessellate LINK curve_tessellate)
@@ -0,0 +1,65 @@
// curve_tessellate.cpp — see curve_tessellate.h. Pure geometry; no host types.
#include "core/instrument/ui/curve_tessellate.h"
#include <algorithm>
#include <cstdlib>
#include "core/util/curve_law.h"
namespace reasampler::instrument::ui {
using StrokePoint = reasampler::ui::StrokePoint;
namespace {
StrokePoint pt(double x, double y) {
return StrokePoint{static_cast<float>(x), static_cast<float>(y)};
}
// The samples strictly BETWEEN two nodes. The endpoints are the caller's, so a shared node is
// emitted once and the polyline carries no zero-length joint.
void appendInterior(int x0, int y0, int x1, int y1, double exponent,
std::vector<StrokePoint>& out) {
const int span = std::abs(x1 - x0);
if (span < 2 || y1 == y0 || exponent == util::kCurveNeutral) return;
const double dx = static_cast<double>(x1 - x0);
const double dy = static_cast<double>(y1 - y0);
for (int i = 1; i < span; ++i) {
// phi is exact at every column, so x lands on the integer column and the last interior
// sample is one column short of the end node.
const double phi = static_cast<double>(i) / static_cast<double>(span);
out.push_back(pt(static_cast<double>(x0) + dx * phi,
static_cast<double>(y0) + dy * util::curveMap(phi, exponent)));
}
}
} // namespace
double segmentCurve(const StageEnvelope& env, EnvNode endNode) {
switch (endNode) {
case EnvNode::AttackEnd: return env.attackCurve;
case EnvNode::DecayEnd: return env.decayCurve;
case EnvNode::ReleaseEnd: return env.releaseCurve;
default: return util::kCurveNeutral;
}
}
void buildEnvelopeTrace(const std::vector<EnvVertex>& poly, const StageEnvelope& env, int xLo,
int xHi, std::vector<StrokePoint>& out) {
out.clear();
bool started = false;
int px = 0;
int py = 0;
for (const EnvVertex& v : poly) {
if (v.knot) continue;
const int x = std::max(xLo, std::min(xHi, v.x));
if (started) appendInterior(px, py, x, v.y, segmentCurve(env, v.node), out);
out.push_back(pt(x, v.y));
started = true;
px = x;
py = v.y;
}
}
} // namespace reasampler::instrument::ui
+34
View File
@@ -0,0 +1,34 @@
// curve_tessellate.h — the staged envelope's TRACE: envelope_overlay's node vertices joined by
// the curve each stage's exponent defines. Split from that module on the axis the two already
// have — envelope_overlay decides where a node LANDS, this strokes the span BETWEEN two of
// them over phi, so a re-scaled time axis changes nothing here.
#pragma once
#include <vector>
#include "core/instrument/ui/envelope_overlay.h"
#include "core/ui/stroke_aa.h" // StrokePoint — the stroker's own vertex type
namespace reasampler::instrument::ui {
// The exponent governing the segment that ENDS at `node`: attack, decay and release are the
// three sloped stages. Every other node ends a plateau, whose straightness comes from its own
// zero level span rather than from an exponent, so neutral is returned and no caller needs a
// second rule to recognize one.
double segmentCurve(const StageEnvelope& env, EnvNode endNode);
// Replaces `out` with the polyline the shell strokes. Knot vertices are handles, not line
// vertices, and are skipped; node vertices keep their exact INTEGER coordinates (clamped to
// [xLo, xHi]) because those are the positions their draggable handles are drawn at. Only the
// interior samples are sub-pixel, one per pixel column, which is what makes the density follow
// the canvas width instead of a fixed count.
//
// A segment's level runs start + (end - start) * curveMap(phi, exponent) — the composition
// envelopes.h's four evaluators use, so the trace cannot diverge from the sound. A neutral
// exponent or a zero level span emits the two endpoints and nothing between: the straight
// stroke, vertex for vertex.
void buildEnvelopeTrace(const std::vector<EnvVertex>& poly, const StageEnvelope& env, int xLo,
int xHi, std::vector<reasampler::ui::StrokePoint>& out);
} // namespace reasampler::instrument::ui
+116 -61
View File
@@ -4,16 +4,31 @@
#include <utility>
#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs)
namespace reasampler::instrument::ui {
namespace {
int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row the ceiling is PITCH ENV's, whose caption row lands exactly on
// its four-cell knob row at 23. Raising it reflows the deck's first row.
constexpr int kEnvModeSegW = 23;
// The three Staged|Spline mode selectors, as ONE button reading "Stage" or "Spline". Sized so
// each env group's caption row stays no wider than its knob row; the binding group is PITCH
// ENV, which reaches its four-cell knob row at 122 (AMP, the next tightest, at 126).
constexpr int kEnvModeW = 46;
// The single-button enables ("Envelope" / "Filter" / "Limiter"). One width because they are one
// control grammar. MASTER is the group that BINDS it: its knob row measures 130 (the caption
// row, at this width, measures 118), so anything past 64 widens kDeckSpanningW and spends the
// editor's width budget.
constexpr int kEnableBtnW = 52;
DeckToggleDesc enableButton(DeckParam p) {
return {id(p), kEnableBtnW, DeckToggleStyle::kEnable};
}
DeckToggleDesc envModeButton(DeckParam p) {
return {id(p), kEnvModeW, DeckToggleStyle::kMode};
}
} // namespace
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
@@ -23,22 +38,26 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
const bool trigger = (playMode == PlayMode::Trigger);
std::vector<DeckGroupDesc> out;
{
// PITCH/RATE. The three cells make the knob row 180, which is what the group measures
// from; the caption row (caption + gap + the 96px toggle) must stay under it, so the
// caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob
// row and the group grows past 192. Widening the group is not the answer if the text ever
// outgrows 80: narrow the Varisp|Presrv toggle to 88 instead.
DeckGroupDesc pitch;
pitch.id = kGroupPitch;
pitch.captionWidth = 38;
pitch.captionToggle = {id(DeckParam::kPitchEngine), 48};
pitch.cellIds = {id(DeckParam::kKeyTrack)};
pitch.captionWidth = 70;
pitch.captionToggle = {id(DeckParam::kPitchEngine), 96};
pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)};
out.push_back(std::move(pitch));
}
{
DeckGroupDesc penv;
penv.id = kGroupPitchEnv;
penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
penv.captionToggle = enableButton(DeckParam::kPitchEnvEnable);
// The mode toggle rides the caption slack rather than the knob row — costs no group
// width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on.
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
penv.captionToggle2 = envModeButton(DeckParam::kPitchEnvMode);
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold),
id(DeckParam::kPitchEnvDecay),
@@ -46,36 +65,41 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
out.push_back(std::move(penv));
}
{
// Tone shaping left-to-right, then the three modulation depths that all target cutoff.
// Tone shaping left-to-right, then the two modulation depths that stay with the tone
// stage. The envelope's own depth sits with the envelope — see FILTER ENV below.
DeckGroupDesc filter;
filter.id = kGroupFilter;
filter.captionWidth = 46;
filter.captionToggle = {id(DeckParam::kFilterEnable), 32};
filter.captionToggle = enableButton(DeckParam::kFilterEnable);
filter.cellIds = {id(DeckParam::kFilterMorph),
id(DeckParam::kFilterCutoff),
id(DeckParam::kFilterQ),
id(DeckParam::kFilterDrive),
id(DeckParam::kFilterModAmt),
id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)};
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
// The morph law rides the caption slack. Moving it back to the knob row costs the
// group 92px and the SOUND row stops fitting its block.
filter.captionToggle2 = {id(DeckParam::kFilterLaw), 88};
out.push_back(std::move(filter));
}
{
DeckGroupDesc fenv;
fenv.id = kGroupFilterEnv;
fenv.captionWidth = 66;
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
fenv.captionToggle2 = envModeButton(DeckParam::kFilterEnvMode);
// The mod DEPTH sits with the envelope it scales, last in the run — the kPitchEnvDepth
// precedent above. Why moving it renumbers nothing is param_id.h's, at the id table.
if (trigger) {
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterTrigDecay), -1, -1};
id(DeckParam::kFilterTrigDecay), -1, -1,
id(DeckParam::kFilterModAmt)};
} else {
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
id(DeckParam::kFilterEnvHold),
id(DeckParam::kFilterEnvDecay),
id(DeckParam::kFilterEnvSustain),
id(DeckParam::kFilterEnvRelease)};
id(DeckParam::kFilterEnvRelease),
id(DeckParam::kFilterModAmt)};
}
out.push_back(std::move(fenv));
}
@@ -83,9 +107,8 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
DeckGroupDesc amp;
amp.id = kGroupAmpEnv;
amp.captionWidth = 78;
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
amp.captionToggle = {id(DeckParam::kPlayMode), 88};
amp.captionToggle2 = envModeButton(DeckParam::kAmpEnvMode);
if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One reserve (-1) keeps the group's width —
@@ -115,21 +138,53 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
DeckGroupDesc voice;
voice.id = kGroupVoice;
voice.captionWidth = 38;
voice.captionToggle = {id(DeckParam::kVoiceMode), 40};
voice.captionToggle = {id(DeckParam::kVoiceMode), 80};
voice.cellIds = {id(DeckParam::kVoiceCount)};
voice.rowToggle = {id(DeckParam::kMonoTrigger), 44};
voice.rowToggle = {id(DeckParam::kMonoTrigger), 88};
out.push_back(std::move(voice));
}
{
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
// dashed placeholder would read as unfinished. It is one cell, not two — a second
// would spend 60 of the layout's whole 82px budget on a control nobody has named.
DeckGroupDesc master;
master.id = kGroupMaster;
master.captionWidth = 46;
master.cellIds = {id(DeckParam::kMasterGain)};
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
master.captionToggle = enableButton(DeckParam::kLimiterEnable);
master.cellIds = {id(DeckParam::kMasterGain), -1};
// The reserve IS what the interior consumes — read from master_meter rather than
// restated, so the two cannot drift when the column grows into MASTER's banked room.
master.column = {id(DeckParam::kMasterMeter), kMeterColumnW};
out.push_back(std::move(master));
}
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
return out;
}
DeckRow deckRowFor(DeckGroupId group) {
// Every enumerator listed and no default, on the same gate deckParamCommit below relies on.
switch (group) {
case kGroupPitch:
case kGroupFilter:
case kGroupVelocity:
case kGroupVoice:
return DeckRow::Sound;
case kGroupPitchEnv:
case kGroupFilterEnv:
case kGroupAmpEnv:
return DeckRow::Contour;
case kGroupMaster:
return DeckRow::Spanning;
}
// Unreachable for a valid enumerator, and Spanning rather than Sound ON PURPOSE: the
// -Wswitch gate is compiler-dependent, so on a toolchain that does not raise it a dropped
// case arm falls here instead. Sound is what a new group most plausibly IS, which would
// make the fall-through invisible; Spanning is the one row nothing may silently join, so
// the tests' partition count catches it.
return DeckRow::Spanning;
}
CurveTarget curveTargetFor(int controlId) {
switch (static_cast<DeckParam>(controlId)) {
case DeckParam::kAmpVelCurve: return CurveTarget::kAmp;
@@ -157,8 +212,19 @@ DeckParam curveParamFor(DeckParam knob) {
}
}
bool isLiveDeckParam(DeckParam id) {
LiveCommit deckParamCommit(DeckParam id) {
switch (id) {
// The note-on-latched controls; the header owns why each one latches.
case DeckParam::kRate:
case DeckParam::kKeyTrack:
case DeckParam::kTrigLength:
return LiveCommit::NoteOnLatched;
// Live by the tier's own definition — one atomic store the audio thread picks up at the
// next block, no bridge read and no re-decode. It reaches the audio beside the live
// block rather than through it, which is why it carries no LiveValues field; that is a
// question of ROUTE, and this predicate answers TIER.
case DeckParam::kMasterGain:
case DeckParam::kPitch:
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
@@ -198,15 +264,14 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
return true;
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
// instead of silently defaulting to non-live. Reasons live in the header.
return LiveCommit::Live;
// Listed rather than defaulted so a newly added control is a COMPILE error here on
// every toolchain — /we4062 on MSVC, -Werror=switch on GCC/Clang, both set on this
// library alone in cmake/reasampler_targets.cmake — instead of silently defaulting
// to non-live. Reasons live in the header.
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
@@ -223,34 +288,23 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain:
case DeckParam::kLimiterEnable:
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
// radios above.
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount: // not a control
return false;
return LiveCommit::Reload;
}
return false; // unreachable for a valid enumerator; silences a warning.
return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning.
}
OverlayEnv overlayEnvForRadio(int radioId) {
switch (static_cast<DeckParam>(radioId)) {
case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
const OverlayEnv picked = overlayEnvForRadio(radioId);
if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects
return (current == picked) ? OverlayEnv::kNone : picked;
}
OverlayEnv overlayEnvForModeToggle(int toggleId) {
switch (static_cast<DeckParam>(toggleId)) {
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
OverlayEnv overlayEnvForGroup(int groupId) {
switch (groupId) {
case kGroupAmpEnv: return OverlayEnv::kAmp;
case kGroupPitchEnv: return OverlayEnv::kPitch;
case kGroupFilterEnv: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
@@ -325,17 +379,18 @@ bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
}
}
bool liveCommitFor(LiveDragKind kind, int paramId) {
LiveCommit liveCommitFor(LiveDragKind kind, int paramId) {
switch (kind) {
case LiveDragKind::kDeckKnob:
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
return (paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount))
? deckParamCommit(static_cast<DeckParam>(paramId))
: LiveCommit::Reload;
case LiveDragKind::kEnvNode:
return true;
return LiveCommit::Live;
case LiveDragKind::kOther:
return false;
return LiveCommit::Reload;
}
return false;
return LiveCommit::Reload;
}
} // namespace reasampler::instrument::ui
+66 -26
View File
@@ -32,6 +32,8 @@ enum class DeckParam {
kPitchEnvDecay,
kPitchEnvDepth, // AHD pitch depth in +/- semitones
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
kRate, // playback rate 50..200%, linear in semitones over +/-12
kPitch, // baseline pitch offset, +/-kPitchDepthMaxSemis, centre-expanded
// Filter. The four control positions map through filter_params' own laws; the three
// depths are bipolar and centred at zero.
kFilterEnable, // filter on|off caption toggle
@@ -70,7 +72,9 @@ enum class DeckParam {
kAmpVelCurve,
kPitchVelCurve,
kFilterVelCurve,
// Overlay selection radios — transient view state, not parameters.
// Retired overlay-selection radios. The deck itself is the overlay's click target now, so
// no group carries these — they survive only because param/'s frozen tables enumerate the
// whole id space and must stay total. Do not re-add a radio for them.
kAmpEnvSelect,
kPitchEnvSelect,
kFilterEnvSelect,
@@ -85,6 +89,11 @@ enum class DeckParam {
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
kLimiterEnable, // master-bus limiter Off | On caption toggle (MASTER group)
// MASTER's two readouts. Neither reaches a parameter: the meter's only gesture is the
// click that clears its latched clip cap, and the bubble is a passive lamp.
kMasterMeter,
kMasterGr,
kCount
};
@@ -101,6 +110,12 @@ enum DeckGroupId {
kGroupMaster,
};
// Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what
// reads it). Membership is a property of the GROUP; width is a property of its descriptor.
// Total over DeckGroupId by an exhaustive switch with no default, so a group added without a
// row cannot silently become Sound.
DeckRow deckRowFor(DeckGroupId group);
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
// one place a control id resolves to a curve target — paint (draw a curve thumbnail, not a
// dial) and hit-test (open a popup, not start a drag) both read this predicate rather than
@@ -121,15 +136,24 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// ordinary knob grab.
DeckParam curveParamFor(DeckParam knob);
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
// rather than through an instrument reload. The line is drawn at continuously-valued playback
// controls, so this is a routing decision at the editor's commit site rather than a property
// of any one knob; moving a control across the line is a change here and nowhere else.
// How an edit to a control reaches the audio — THE one decision, and the home for why each
// control sits where it does. Moving a control across a line is a change here and nowhere else,
// and Γ-W4-T1 derives the host-exposed parameter set from this same predicate, so a
// misclassification here is a mis-declared parameter there.
//
// THE home for why each excluded control is excluded. Three continuous controls are outside
// the live set, plus every discrete toggle and the overlay radios:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
// name a different sound rather than a different setting of one;
// Live — straight to the voices already sounding. Continuously-valued playback
// controls, and the default for anything that is a SETTING of a note rather
// than a fact about it.
// NoteOnLatched — published into the live block like a live control, but read only at
// note-on: a sounding voice keeps the value it started with, the next one
// takes the new one. NOT the reload tier — a swept knob must never trigger a
// WAV re-decode.
// Reload — a bridge read, a re-decode and a fresh engine.
//
// The exclusions from Live, each with its reason:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable,
// the three Staged|Spline mode toggles) name a different sound rather than a different
// setting of one;
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
// the decoded PCM;
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
@@ -141,32 +165,51 @@ DeckParam curveParamFor(DeckParam knob);
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
bool isLiveDeckParam(DeckParam id);
//
// kMasterGain is Live and is the one live control that does NOT ride the live block: it is a
// lock-free atomic on the processor which the audio thread applies as a post-sum multiply. The
// tier answers "does an edit reach the audio without a reload", not "which mechanism carries
// it" — classifying it Reload would have said a gain move re-decodes the WAV, which it never did.
//
// THREE controls are NoteOnLatched: kRate, kKeyTrack and kTrigLength. The exclusions list above
// already gives the latter two their reason — both were Reload until they were promoted so they
// could be automated at all, since a reload per automation point re-decodes the WAV. kRate's
// reason is its own, and is a real feature rather than a plumbing detail: loop points and
// contours both scale with rate, and both are note-on folds — resolveLoop runs once per note-on
// and a contour resolves against the note's own span. A live rate would mean re-folding an
// already-resolved loop and re-mapping a contour mid-note without a discontinuity. kPitch is not
// implicated and is ordinarily Live.
enum class LiveCommit { Live, NoteOnLatched, Reload };
LiveCommit deckParamCommit(DeckParam id);
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
// shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is
// testable without a host.
enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
// reaches the enum); an envelope-node drag is live in either mode, since every stage value it
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
bool liveCommitFor(LiveDragKind kind, int paramId);
// How a drag of `kind` commits. A deck knob answers per deckParamCommit (negative ids are the
// shell's processor-side sentinels and out-of-range ids are not controls, so neither reaches the
// enum); an envelope-node drag is Live in either mode, since every stage value it can reach —
// AHDSR or AHD, on any of the three envelopes — is itself Live.
//
// Live and NoteOnLatched take the SAME route out of the editor — one publish of the live block,
// no reload, no engine rebuild. They differ only in who reads the published value, which is the
// engine's business (live_params.h), so the shell needs the distinction only to know that
// neither reloads.
LiveCommit liveCommitFor(LiveDragKind kind, int paramId);
// Which envelope the waveform overlay draws and edits. Exclusive across the three envelope
// decks, and kNone is a valid resting state — the editor opens there. Transient view state:
// never persisted, never a parameter.
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
// The envelope a deck's overlay-select radio picks; kNone for any other control id.
OverlayEnv overlayEnvForRadio(int radioId);
// The selection a click on `radioId` produces from `current`. Two rules, provable here rather
// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking
// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back
// to, not an error. A non-radio id leaves the selection alone.
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
// The selection a click inside deck group `groupId` produces — THE overlay focus map, and the
// whole state machine now that the deck is its own click target. An envelope deck names its
// envelope; every other group, and every point outside the deck (-1), names kNone, which is
// how a click on a control surface outside the envelope decks clears the focus. Exclusive and
// IDEMPOTENT by construction: re-clicking a focused deck re-selects it rather than toggling to
// none, so a knob tweak on the focused deck cannot unfocus it.
OverlayEnv overlayEnvForGroup(int groupId);
// The group states the two inert predicates below read. One struct rather than a growing
// parameter list, so adding a gate is a change at the two predicates and nowhere else.
@@ -178,9 +221,6 @@ struct DeckEnableState {
bool filterSpline = false;
};
// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id.
OverlayEnv overlayEnvForModeToggle(int toggleId);
// Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on.
// The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs.
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state);
+259 -104
View File
@@ -3,9 +3,10 @@
#include "core/instrument/ui/deck_values.h"
#include <algorithm>
#include <cstdio>
#include <cmath>
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
@@ -14,13 +15,6 @@ namespace reasampler::instrument::ui {
using engine::filter::MorphLaw;
using util::clamp01;
namespace {
double secToNorm(double seconds) { return clamp01(seconds / kEnvTimeMaxSeconds); }
double normToSec(double norm) { return clamp01(norm) * kEnvTimeMaxSeconds; }
} // namespace
double deckParamNorm(DeckParam id, const PlaySeconds& play) {
switch (id) {
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
@@ -28,31 +22,36 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds);
case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds);
case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds);
case DeckParam::kRate:
return rateNormFromRatio(play.playRate, kRateMinRatio, kRateMaxRatio);
case DeckParam::kPitch:
return depthNormFromSemitones(play.pitchOffsetSemitones, kPitchDepthMaxSemis);
case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds);
case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds);
case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds);
case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel);
case DeckParam::kRelease: return secToNorm(play.adsr.releaseSeconds);
case DeckParam::kRelease: return timeNormFromSeconds(play.adsr.releaseSeconds);
case DeckParam::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction);
case DeckParam::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
case DeckParam::kTrigAttack: return timeNormFromSeconds(play.trigAhd.attackSeconds);
case DeckParam::kTrigHold: return clamp01(play.trigAhd.holdFraction);
case DeckParam::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
case DeckParam::kTrigDecay: return timeNormFromSeconds(play.trigAhd.decaySeconds);
case DeckParam::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0;
case DeckParam::kPitchEnvAttack: return secToNorm(play.pitchEnv.shape.attackSeconds);
case DeckParam::kPitchEnvAttack:
return timeNormFromSeconds(play.pitchEnv.shape.attackSeconds);
case DeckParam::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
case DeckParam::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
case DeckParam::kPitchEnvDecay:
return timeNormFromSeconds(play.pitchEnv.shape.decaySeconds);
case DeckParam::kPitchEnvAttackCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
case DeckParam::kPitchEnvDecayCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
case DeckParam::kPitchEnvDepth:
// Signed depth centred at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
return depthNormFromSemitones(play.pitchEnv.peakSemitones, kPitchDepthMaxSemis);
// Filter. The four tone controls ARE the module's normalized positions — stored and
// shown as-is, so the knob travel is exactly filter_params' own law.
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
@@ -64,21 +63,24 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
case DeckParam::kFilterKeyTrack: return keyTrackNormFrom(play.filter.keyTrack);
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
case DeckParam::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
case DeckParam::kFilterEnvRelease:
return timeNormFromSeconds(play.filter.env.releaseSeconds);
case DeckParam::kFilterEnvAttackCurve:
return util::knobNormFromCurve(play.filter.env.attackCurve);
case DeckParam::kFilterEnvDecayCurve:
return util::knobNormFromCurve(play.filter.env.decayCurve);
case DeckParam::kFilterEnvReleaseCurve:
return util::knobNormFromCurve(play.filter.env.releaseCurve);
case DeckParam::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
case DeckParam::kFilterTrigAttack:
return timeNormFromSeconds(play.filter.trigEnv.attackSeconds);
case DeckParam::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
case DeckParam::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
case DeckParam::kFilterTrigDecay:
return timeNormFromSeconds(play.filter.trigEnv.decaySeconds);
case DeckParam::kFilterTrigAttackCurve:
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
case DeckParam::kFilterTrigDecayCurve:
@@ -87,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
}
}
double storedFromNorm(DeckParam id, double norm) {
switch (id) {
// The filter's four STORE their normalized position (payload v9), so the identity IS
// their law — deckFloatField's four, and the reason it is a separate resolver.
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
return clamp01(norm);
case DeckParam::kRate:
return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio);
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis);
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return deckBipolarFromNorm(norm);
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return keyTrackFromNorm(norm);
case DeckParam::kTrigLength:
// lengthFraction is (0,1]; a small floor so a zero-length trigger never plays
// nothing.
return (std::max)(0.01, clamp01(norm));
default:
break;
}
// The rest are decided by the display unit alone, which is what makes the fourteen stage
// times and the twelve curve dials one line each rather than twenty-six.
switch (deckParamUnit(id)) {
case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm);
case UnitCategory::Exponent: return util::curveFromKnobNorm(norm);
case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels
// Decibels is master gain, whose stored value is a LINEAR gain the processor owns
// rather than a field of the parameter set; the two enums above are handled by id.
case UnitCategory::Semitones:
case UnitCategory::Decibels:
case UnitCategory::None:
break;
}
return norm;
}
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
switch (id) {
case DeckParam::kPlayMode:
@@ -110,84 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break;
case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case DeckParam::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
// nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
case DeckParam::kTrigAttackCurve:
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigDecayCurve:
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
case DeckParam::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
case DeckParam::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case DeckParam::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
case DeckParam::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
case DeckParam::kFilterLaw:
play.filter.settings.morphLaw =
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
break;
case DeckParam::kFilterMorph:
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterCutoff:
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterQ:
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterDrive:
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterKeyTrack:
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
case DeckParam::kFilterEnvAttack:
play.filter.env.attackSeconds = normToSec(value); break;
case DeckParam::kFilterEnvHold:
play.filter.env.holdSeconds = normToSec(value); break;
case DeckParam::kFilterEnvDecay:
play.filter.env.decaySeconds = normToSec(value); break;
case DeckParam::kFilterEnvSustain:
play.filter.env.sustainLevel = clamp01(value); break;
case DeckParam::kFilterEnvRelease:
play.filter.env.releaseSeconds = normToSec(value); break;
case DeckParam::kFilterEnvAttackCurve:
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvDecayCurve:
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = normToSec(value); break;
case DeckParam::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case DeckParam::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = normToSec(value); break;
case DeckParam::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
default: break;
default:
// Every knob: the one norm -> stored law, into the one field the control names.
// Both halves are shared with the audio thread's live patch (param/param_live), so
// a control cannot take a different taper or land in a different field depending on
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
// neither field and falls through untouched.
if (float* f = deckFloatField(id, play)) {
*f = static_cast<float>(storedFromNorm(id, value));
} else if (double* d = deckDoubleField(id, play)) {
*d = storedFromNorm(id, value);
}
break;
}
// ONE normalization point for every control that can flip splineActive — a mode toggle
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
@@ -197,15 +182,185 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
enforceGateUnavailableWhileDrawn(play);
}
void resetDeckParam(DeckParam id, PlaySeconds& play) {
const PlaySeconds defaults;
setDeckParam(id, play, deckParamNorm(id, defaults), 0);
int nextToggleSegment(DeckParam id, const PlaySeconds& play) {
switch (id) {
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 0 : 1;
case DeckParam::kFilterEnable: return play.filter.enabled ? 0 : 1;
case DeckParam::kAmpEnvMode: return play.ampSpline.mode == EnvMode::Spline ? 0 : 1;
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 0 : 1;
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 0 : 1;
default: return -1;
}
}
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
if (!buf || len == 0) return;
const double ms = seconds * 1000.0;
std::snprintf(buf, len, ms < 10.0 ? "%.1f ms" : "%.0f ms", ms);
// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry
// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for
// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture
// and resolves to null.
double* deckDoubleField(DeckParam id, PlaySeconds& p) {
switch (id) {
case DeckParam::kRate: return &p.playRate;
case DeckParam::kPitch: return &p.pitchOffsetSemitones;
case DeckParam::kAttack: return &p.adsr.attackSeconds;
case DeckParam::kHold: return &p.adsr.holdSeconds;
case DeckParam::kDecay: return &p.adsr.decaySeconds;
case DeckParam::kSustain: return &p.adsr.sustainLevel;
case DeckParam::kRelease: return &p.adsr.releaseSeconds;
case DeckParam::kAttackCurve: return &p.adsr.attackCurve;
case DeckParam::kDecayCurve: return &p.adsr.decayCurve;
case DeckParam::kReleaseCurve: return &p.adsr.releaseCurve;
case DeckParam::kTrigLength: return &p.trigger.lengthFraction;
case DeckParam::kTrigAttack: return &p.trigAhd.attackSeconds;
case DeckParam::kTrigHold: return &p.trigAhd.holdFraction;
case DeckParam::kTrigDecay: return &p.trigAhd.decaySeconds;
case DeckParam::kTrigAttackCurve: return &p.trigAhd.attackCurve;
case DeckParam::kTrigDecayCurve: return &p.trigAhd.decayCurve;
case DeckParam::kPitchEnvAttack: return &p.pitchEnv.shape.attackSeconds;
case DeckParam::kPitchEnvHold: return &p.pitchEnv.shape.holdFraction;
case DeckParam::kPitchEnvDecay: return &p.pitchEnv.shape.decaySeconds;
case DeckParam::kPitchEnvAttackCurve: return &p.pitchEnv.shape.attackCurve;
case DeckParam::kPitchEnvDecayCurve: return &p.pitchEnv.shape.decayCurve;
case DeckParam::kPitchEnvDepth: return &p.pitchEnv.peakSemitones;
case DeckParam::kFilterModAmt: return &p.filter.modAmount;
case DeckParam::kFilterVel: return &p.filter.velAmount;
case DeckParam::kFilterKeyTrack: return &p.filter.keyTrack;
case DeckParam::kFilterEnvAttack: return &p.filter.env.attackSeconds;
case DeckParam::kFilterEnvHold: return &p.filter.env.holdSeconds;
case DeckParam::kFilterEnvDecay: return &p.filter.env.decaySeconds;
case DeckParam::kFilterEnvSustain: return &p.filter.env.sustainLevel;
case DeckParam::kFilterEnvRelease: return &p.filter.env.releaseSeconds;
case DeckParam::kFilterEnvAttackCurve: return &p.filter.env.attackCurve;
case DeckParam::kFilterEnvDecayCurve: return &p.filter.env.decayCurve;
case DeckParam::kFilterEnvReleaseCurve: return &p.filter.env.releaseCurve;
case DeckParam::kFilterTrigAttack: return &p.filter.trigEnv.attackSeconds;
case DeckParam::kFilterTrigHold: return &p.filter.trigEnv.holdFraction;
case DeckParam::kFilterTrigDecay: return &p.filter.trigEnv.decaySeconds;
case DeckParam::kFilterTrigAttackCurve: return &p.filter.trigEnv.attackCurve;
case DeckParam::kFilterTrigDecayCurve: return &p.filter.trigEnv.decayCurve;
default: return nullptr;
}
}
// The filter's four tone controls store their NORMALIZED position, as floats, and their law is
// wire-frozen — hence a second resolver rather than a widened first one.
float* deckFloatField(DeckParam id, PlaySeconds& p) {
switch (id) {
case DeckParam::kFilterMorph: return &p.filter.settings.morphNorm;
case DeckParam::kFilterCutoff: return &p.filter.settings.cutoffNorm;
case DeckParam::kFilterQ: return &p.filter.settings.resonanceNorm;
case DeckParam::kFilterDrive: return &p.filter.settings.driveNorm;
default: return nullptr;
}
}
void resetDeckParam(DeckParam id, PlaySeconds& play) {
PlaySeconds defaults;
if (double* dst = deckDoubleField(id, play)) {
*dst = *deckDoubleField(id, defaults);
return;
}
if (float* dst = deckFloatField(id, play)) *dst = *deckFloatField(id, defaults);
}
UnitCategory deckParamUnit(DeckParam id) {
switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigDecay:
return UnitCategory::Milliseconds;
// Rate DISPLAYS as a percent but its unit is the semitone — that is what puts an octave
// and a fifth under Shift, which a whole-percent snap could not reach.
case DeckParam::kRate:
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return UnitCategory::Semitones;
// The filter's four tone controls read out in Hz / Q / drive depth but snap in whole
// percent of the normalized position they STORE — display and snap are independent axes.
case DeckParam::kSustain:
case DeckParam::kTrigLength:
case DeckParam::kTrigHold:
case DeckParam::kPitchEnvHold:
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterTrigHold:
return UnitCategory::Percent;
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
return UnitCategory::Exponent;
case DeckParam::kMasterGain:
return UnitCategory::Decibels;
default:
// Toggles, radios, the curve-popup cells, and the already-integer voice count.
return UnitCategory::None;
}
}
double snapDeckParamNorm(DeckParam id, double norm) {
switch (deckParamUnit(id)) {
case UnitCategory::Milliseconds:
return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm)));
case UnitCategory::Semitones:
// Rate's semitones live in the ratio domain, so its snap round-trips through the rate
// taper rather than the depth one; the other two share the depth throw.
if (id == DeckParam::kRate) {
return rateNormFromRatio(
snapRateRatioToWholeSemitone(
rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio)),
kRateMinRatio, kRateMaxRatio);
}
return depthNormFromSemitones(
snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)),
kPitchDepthMaxSemis);
case UnitCategory::Exponent:
return util::knobNormFromCurve(snapExponentToWhole(util::curveFromKnobNorm(norm)));
case UnitCategory::Decibels:
return engine::masterGainNormFromDb(
std::nearbyint(engine::masterGainDbFromNorm(norm)));
case UnitCategory::Percent:
switch (id) {
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return deckNormFromBipolar(
snapFractionToWholePercent(deckBipolarFromNorm(norm)));
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return keyTrackNormFrom(
snapFractionToWholePercent(keyTrackFromNorm(norm)));
default:
return clamp01(snapFractionToWholePercent(clamp01(norm)));
}
case UnitCategory::None:
break;
}
return norm;
}
} // namespace reasampler::instrument::ui
+64 -21
View File
@@ -1,24 +1,25 @@
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
// what each one's value MEANS.
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its snap units: the
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, and
// the double-click reset. Split from the editor shell so the whole domain map is provable
// without a host; deck_groups owns WHICH controls exist, this owns what each one's value MEANS,
// and param/param_format owns how it READS.
#pragma once
#include <cstddef>
#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range)
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
#include "core/util/clamp01.h"
namespace reasampler::instrument::ui {
using map::PlaySeconds;
// Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the
// parameter set stores. READ from the overlay's schematic scale rather than restated: the AHDSR
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
// parameter set stores. An ALIAS of the overlay's schematic domain, which is itself an alias of
// the taper's; param_taper.h owns why the number has one home.
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch
@@ -28,27 +29,69 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
inline constexpr double kKeyTrackMax = 2.0;
// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is,
// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed
// by per-instance state rather than the parameter set (voice count, master gain, the pitch
// key-track scalar, preview velocity) are not here — the shell reads those from the processor.
// The key-track norm <-> stored pair, shared by BOTH key-track controls. It gets its own home
// because the pitch one's value lives beside the play bundle (on InstrumentParams / SampleData)
// and so cannot ride the PlaySeconds binding below — leaving the editor knob, the host's write
// path, the live fold and the snap to each spell the division out. Every one of them calls these.
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
inline constexpr double kRateMinRatio = engine::kStretchRateMin;
inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
// The normalized [0,1] a control shows: stage times through the shared time taper, levels and
// fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over
// their logarithmic travel. Controls backed by per-instance state rather than the parameter set
// (voice count, master gain, the pitch key-track scalar, preview velocity) are not here — the
// shell reads those from the processor.
double deckParamNorm(DeckParam id, const PlaySeconds& play);
// The STORED value a knob's normalized position maps to — the norm -> value half of the binding
// on its own, because the audio thread needs it without a PlaySeconds to write into
// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two
// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar.
double storedFromNorm(DeckParam id, double norm);
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
// Mutates `play` in place, touching exactly the one field the control names.
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
// The segment a SINGLE-BUTTON toggle commits: its NEXT state, derived from what `play` holds
// now, because a one-button control carries no segment of its own. This is what keeps
// setDeckParam's segment contract the only one there is rather than growing a second commit
// path beside it. Answers -1 for every control that is not a single-button parameter-set
// toggle — the segmented ones already carry the segment the user clicked.
int nextToggleSegment(DeckParam id, const PlaySeconds& play);
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
// second table of defaults to drift from the real one. It arrives via the norm round trip, so
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
// second table of defaults to drift from the real one, and the value is COPIED rather than
// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored
// default bit for bit, and no round trip through a log taper over a non-power-of-two ceiling can
// promise that for every control. Never "simplify" it back into a round trip.
// For knob-valued controls — a toggle has no reset gesture.
void resetDeckParam(DeckParam id, PlaySeconds& play);
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
void formatEnvTimeMs(double seconds, char* buf, std::size_t len);
// The ADDRESS of the one stored field `id` owns — the mechanism resetDeckParam bypasses the taper
// with. Exposed beyond that one caller so a test can verify a reset (or any other mutation)
// against the actual stored field rather than its normalized read-back, which deckParamNorm does
// not guarantee is injective. Null for a control with no reset gesture (a toggle, radio, or
// curve-popup cell) or one whose value lives outside PlaySeconds (master gain, key-track).
double* deckDoubleField(DeckParam id, PlaySeconds& p);
// The filter's four tone controls store their normalized position as float — see deckFloatField's
// definition for why that is a second resolver rather than a widened first one.
float* deckFloatField(DeckParam id, PlaySeconds& p);
// THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's
// processor-side ids (voice count, master gain), which have no entry in the two functions above
// because their VALUE lives outside the parameter set — the unit does not.
UnitCategory deckParamUnit(DeckParam id);
// Applies that snap to a control's normalized value. Snapping happens in the DISPLAYED unit, so
// this is where each control's full scale enters: 0..100 %, 0..200 % and +/-100 % all snap to a
// whole displayed percent and therefore take different norm steps.
double snapDeckParamNorm(DeckParam id, double norm);
} // namespace reasampler::instrument::ui
+99 -40
View File
@@ -11,8 +11,8 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveFromMidLevel;
using util::curveMidLevel;
using util::curveFromLevelAt;
using util::curveLevelAt;
namespace {
@@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
return totalSeconds / static_cast<double>(w);
}
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// dragged handle tracks the cursor 1:1.
double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0;
// The exact inverse of gatePolyline's tapered stage placement: a stage's drawn offset inside its
// slot is slot * timeNormFromSeconds(t), so a pixel delta moves the NORM by dx/slot — never the
// seconds by a fixed rate. Reading the same taper the draw does is what makes a dragged handle
// track the cursor at both ends of the range instead of only near the ceiling.
double gateStageFromPixels(double grabSeconds, const Rect& area, double dxPixels) {
const double slot = gateStageSlotPx(area);
if (slot <= 0.0) return grabSeconds;
return timeSecondsFromNorm(timeNormFromSeconds(grabSeconds) + dxPixels / slot);
}
// Shift's snaps, applied to the resolved param before its clamp so the domain edge always wins.
double snappedSeconds(double seconds, const DragModifiers& m) {
return m.shift ? snapSecondsToWholeMs(seconds) : seconds;
}
double snappedFraction(double fraction, const DragModifiers& m) {
return m.shift ? snapFractionToWholePercent(fraction) : fraction;
}
double snappedExponent(double exponent, const DragModifiers& m) {
return m.shift ? snapExponentToWhole(exponent) : exponent;
}
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
@@ -91,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
return s;
}
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the
// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's.
struct SegmentPixels {
int x0 = 0;
int x1 = 0;
bool ok = false;
};
SegmentPixels segmentPixels(const std::vector<EnvVertex>& poly, EnvNode knot) {
EnvNode startNode, endNode;
switch (knot) {
case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break;
case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break;
case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break;
default: return {};
}
SegmentPixels s;
bool haveStart = false, haveEnd = false;
for (const EnvVertex& v : poly) {
if (v.node == startNode) { s.x0 = v.x; haveStart = true; }
else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; }
}
s.ok = haveStart && haveEnd;
return s;
}
// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see
// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi.
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, int dyPixels) {
double phi, const Rect& area, double dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double span = seg.end - seg.start;
@@ -103,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
// level travel — a segment thinner than that is visually a no-op drag anyway.
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / span);
const double grabLevel = seg.start + span * curveLevelAt(phi, grabExponent);
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
return curveFromLevelAt(phi, (newLevel - seg.start) / span);
}
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
@@ -150,15 +189,29 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
int dxPixels, int dyPixels, const DragModifiers& mods) {
StageEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
const Rect& rect = area.rect;
const double secPerPx = secondsPerPixel(rect, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
// Fine drag scales the PIXEL delta, so it composes with every axis below (the tapered
// schematic, the 1:1 wall clock, the level and the exponent) without a second rule.
const double scale = fineDrag(mods) ? kFineDragScale : 1.0;
const double dx = static_cast<double>(dxPixels) * scale;
const double dy = static_cast<double>(dyPixels) * scale;
const double dSec = dx * secPerPx;
// A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the
// exact phi the knot is sitting at rather than assuming the segment midpoint.
double curvePhi = 0.5;
if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve ||
node == EnvNode::ReleaseCurve) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(grabEnv, area, totalSeconds);
const SegmentPixels sp = segmentPixels(poly, node);
if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1);
}
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
@@ -166,38 +219,43 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.attackSeconds, rect, dx), mods), 0.0,
bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds =
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
out.holdSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.holdSeconds, rect, dx), mods), 0.0,
bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
out.decaySeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.decaySeconds, rect, dx), mods), 0.0,
bounds.maxDecaySeconds);
const double dLevel = -dy * levelPerPixel(rect);
out.sustainLevel =
std::clamp(snappedFraction(grabEnv.sustainLevel + dLevel, mods), 0.0, 1.0);
break;
}
case EnvNode::ReleaseStart:
// The release runs from this node to the anchored right edge, so dragging LEFT
// (negative dx) lengthens it — the delta enters with the opposite sign.
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
out.releaseSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.releaseSeconds, rect, -dx), mods), 0.0,
bounds.maxReleaseSeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
out.releaseCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods);
break;
default:
break;
@@ -209,8 +267,8 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
const AhdSplit s = splitAhdSeconds(grabEnv);
switch (node) {
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds = std::clamp(snappedSeconds(grabEnv.attackSeconds + dSec, mods), 0.0,
bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd: {
// Hold is a fraction of what attack and decay left, so the node's pixel motion
@@ -218,7 +276,7 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// nothing the drag could express.
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
if (rem <= 0.0) break;
out.holdFraction = clamp01((s.hold + dSec) / rem);
out.holdFraction = clamp01(snappedFraction((s.hold + dSec) / rem, mods));
break;
}
case EnvNode::DecayEnd: {
@@ -231,17 +289,18 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// unchanged rather than divided by zero.
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
if (denom > 1e-9) {
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
out.decaySeconds = std::clamp(snappedSeconds(grabEnv.decaySeconds + dSec / denom, mods),
0.0, bounds.maxDecaySeconds);
}
break;
}
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
default:
break;
+6 -3
View File
@@ -51,15 +51,18 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
// `dxPixels`/`dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
// [0, per-param max].
// * X delta -> the node's time param, through the same map the forward draw used — the tapered
// slot on an AHDSR, 1:1 wall clock on an AHD — clamped to [0, per-param max].
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
// exponent. Ignored for time-only nodes.
// * `mods` carries the shared interaction law (param_taper.h): Ctrl scales the pixel delta,
// Shift snaps the resolved param to a whole unit of its own category before the clamp. The
// shell RE-ANCHORS on every modifier transition, so `mods` is constant across one delta.
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
// `grabEnv` unchanged.
// Only the dragged node's param(s) change. Pure.
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
int dxPixels, int dyPixels, const DragModifiers& mods = {});
} // namespace reasampler::instrument::ui

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