diff --git a/PLAN.md b/PLAN.md index 01882ab..b2b2c99 100644 --- a/PLAN.md +++ b/PLAN.md @@ -381,16 +381,16 @@ cleanly (treated as generation 0; first bump refreshes). side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed alongside S8. -- [ ] Writer (extension): a monotonic **bank-generation counter** stamped into +- [x] Writer (extension): a monotonic **bank-generation counter** stamped into `"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), bumped on every bank-content mutation that changes what an instance would play (capture add, recapture-in-place, sample-remove, move/copy affecting the active bank). Additive to the persist blob; defaults to 0 for projects saved before the stamp exists. -- [ ] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread +- [x] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`. -- [ ] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time +- [x] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time residual — a low-frequency UI timer, coalescing multiple bumps between polls into one reload). **Must-verify before build:** that a bridge ext-state read on the instrument's UI/timer thread is safe against a concurrent extension write (the read already tolerates a @@ -576,7 +576,10 @@ into the voice engine; today they are fixed defaults), S5 (the map the numeric f > search** (a name-substring narrow over the same cards, composing with S10's bank filter — > bank filter picks the bank, search narrows within it). The scroll/search geometry is pure, > layered over the `capture_browser` module S10 builds. Net: S12 = scroll + search over the -> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. +> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. **S12 now also +> carries the S15/S16 control surfaces** (per-zone Gate/Trigger mode toggle, AHDSR hold +> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch +> envelope depth/shape controls) — deferred here from S15 and S16 per spec. - [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a bank longer than the panel is fully reachable; a **type-to-filter search** that narrows @@ -657,7 +660,7 @@ independent of the S7 channel dimension (§sequencing). shows start + %-length + fades on the **same** waveform surface). Independent of S7 — orthogonal dimensions (§sequencing note in CONTEXT.md). -- [ ] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows +- [x] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows `AdsrParams` → `AhdsrParams` (add `holdFrames` between attack and decay; hold=0 is the exact current ADSR — back-compat). **Trigger** is a distinct envelope: play `[start, start + lengthFraction·(frames−start))` with a **fade-in** ramp (0→1 over `fadeInFrames`) @@ -665,15 +668,15 @@ orthogonal dimensions (§sequencing note in CONTEXT.md). **ignoring note-off** (release is a no-op in Trigger). Fade curve default **equal-power** (constant-power `sin`/`cos`, click-free on one-shots) with the shape noted; linear is a build-time residual. Pure, unit-tested against a known signal. -- [ ] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame` +- [x] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame` (frame offset), applied in both modes; the existing per-frame `readPos_ += ratio_` read and loop/interp machinery is otherwise unchanged. Clamp `0 ≤ startFrame < frames`. -- [ ] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]` +- [x] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]` resolves to `playEnd = start + round(lengthFraction·(frames − start))`; `fadeInFrames` / `fadeOutFrames` clamp so their sum ≤ play length (fade-out anchored to `playEnd`). Note-off in Trigger does nothing; the voice frees when `readPos_ ≥ playEnd` (mirror of the current run-off-end idle). **Choke on note-off is NOT in scope** (fork S15-F1, held below). -- [ ] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its +- [x] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its params (Gate: AHDSR; Trigger: %-length, fade-in, fade-out; both: start point) attach to the **capture selection / zone**, stored in the **performance map** (D-B). **Lean (build-time residual):** start point joins `rootOverride`/loop-override as another @@ -685,13 +688,13 @@ orthogonal dimensions (§sequencing note in CONTEXT.md). per-zone only — the single capture is already a one-zone map (S10-Z back-compat lift), so one storage site serves both. Flagged because it touches S10's single-capture setup surface shape. -- [ ] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop +- [x] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop markers** (S11's loop UI); **Trigger** shows **start + %-length end + fade-in/out** handles on the same waveform. A **mode toggle** per capture/zone in the guided setup (S10) / Zones panel (S10-Z). Marker/handle geometry is pure (extends the S11 `frame↔pixel` + marker-grab module); commits off-thread via `commitMapAndReload`. The instrument stays a **read-only bank consumer** (mode/params are performance map, never - written to the bank). + written to the bank). **Editor control surface deferred to S12 tier (spec-sanctioned).** > **S15 × S16 pitch-engine interaction (informs the S16 Preserve engine).** S15's amplitude > semantics are defined over the voice's **source-frame** timeline, which the S16 pitch-engine @@ -759,7 +762,7 @@ per-zone param plumbing + component-state version bump the mode/envelope hang on Gate-loop / Trigger-%-length semantics the Preserve engine must honor — see S15 interaction below). Independent of S7 (both engines operate per-frame, channel-count agnostic). -- [ ] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`. +- [x] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`. **Varispeed** = today's path (`readPos_ += ratio_`, `ratio_ = pitchRatio(note,root)`), pitch and duration coupled. **Preserve** = duration-preserving: the read advances at the **source** rate (duration held) while a pitch shifter transposes the output by @@ -768,7 +771,7 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti Absent/older blob → the fork's default. Pure where possible: the Varispeed math and the duration-invariance contract are unit-tested; the Preserve DSP core is unit-tested for duration invariance and transpose correctness against a known signal. -- [ ] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both +- [x] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both RT-disciplined (pre-allocated, no locks, no `process` allocation): - **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice time-domain OLA shifter. **Now the right category** (duration-preserving is the @@ -787,7 +790,9 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti `WDL_Queue::Add`, an RT hazard *only* until steady state; pre-warm with silence at voice- allocation so the buffers settle and stop growing; (iii) **quality** — basic OLA, audible warble on large transpositions and no formant preservation (`set_formant_shift` is an - empty stub), acceptable for the loop/phrase use case. + empty stub), acceptable for the loop/phrase use case. **Implemented as hand-rolled pure + OLA (`pitch_shift` module, house pattern — CTest-testable, no WDL/REAPER/VST3 type at the + boundary); WDL_SimplePitchShifter excluded by include-chain (windows.h), held as swap.** - **(b) hand-rolled OLA/granular pitch core** as a **pure module** (`pitch_shift`, mirroring the house pattern — CTest-testable, no REAPER/VST3/WDL type at the boundary). More work, but full control over latency/window/crossfade, RT-shape owned by us, and it sits natively @@ -799,7 +804,7 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti Preserve engine. **elastique is NOT available** (licensed zplane, not in the vendored tree — restated, not worked around); JUCE / rubberband / signalsmith are **new-dependency forks carrying full D-A weight** (bare-VST3-no-framework is locked D-A) — not proposed. -- [ ] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false, +- [x] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false, int64 attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time residual):** two-segment AD — at note-on the pitch offset rises to `peakSemitones` over `attackFrames`, then falls to 0 over `decayFrames` (**zero attack** = the pure "start high, @@ -810,14 +815,14 @@ below). Independent of S7 (both engines operate per-frame, channel-count agnosti pitchEnvSemitones(frame))/12))` — so the pitch bends without touching duration. Pure, unit- tested (t=0 offset, t=attack peak, t=attack+decay → 0; and the semitone→shift/ratio mapping for both engines). -- [ ] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone +- [x] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone instrument performance-map state (D-B), additive/version-bumped (absent → engine default per S16-F1, envelope disabled). Editor exposure: a **per-zone Varispeed/Preserve toggle** in the S10 guided setup / S10-Z Zones panel (a two-state control next to the S15 mode toggle), plus a small AD + ±semitone depth control for the envelope (folds into the S12 ADSR-editor tier). Default-off envelope so the control is discoverable but inert until enabled. The instrument stays a **read-only bank consumer** (mode/envelope are performance map, never written to the - bank). + bank). **Editor control surface deferred to S12 tier (spec-sanctioned).** > **S16-F1 (FLAGGED — Daniel fork): the default pitch engine.** **Lean: Preserve default.** > Argued honestly both ways: @@ -1073,7 +1078,7 @@ namespace, so it inherits this isolation with no S18 change; no coordination edi only the shared awareness that all wire keys must live under `kProjExtNamespace()` (already the rule). Dispatchable in parallel with, or immediately after, the in-flight waves. -- [ ] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE +- [x] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE class UID in `reasampler_vst.h` alongside the existing `kReaSamplerProcessorUID` (`REASAMPLER_PROC_UID_1..4`) — a distinct forever-frozen constant (e.g. `REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`). The channel bit selects @@ -1087,7 +1092,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa VST ships). Lean: **mint now** — mirrors how the stable UID was minted at the S1 spike (locked long before ship), removes a "remember to mint before shipping" landmine, and the cost of an unused-until-beta constant is zero. Flagged because it is a forever commitment. -- [ ] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4 +- [x] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4 invariant).** Route all VST identity through `app_version` accessors, mirroring how the extension's `OUTPUT_NAME` already derives from `REASAMPLER_OUTPUT_NAME`. (a) **Binary name:** the CMake VST3 target's `OUTPUT_NAME` forks by channel — `reasampler_9000` @@ -1100,7 +1105,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa `vstPluginName()`), not a literal in `reasampler_vst.h`/`vst_entry.cpp`. (c) **Editor title band + S6 embed-strip label** are channel-aware from the same accessor. All fan out from the ONE channel bit — no per-file `#ifdef`. -- [ ] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The +- [x] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The `BEGIN_FACTORY` vendor/url/email and the `PClassInfo2` version string (`REASAMPLER_VST_VERSION`, currently a fixed `"0.1.0.0"`) align with the channel where the extension's V4 equivalents do — the version display carries the `-beta` render @@ -1108,7 +1113,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa unless V4 qualified them (**check V4's treatment and match it** — V4 kept the lane-name prefix shared, so shared-where-V4-shares is the default). No new forever-stable string beyond the UID and the id-family V4 already owns. -- [ ] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write +- [x] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write the complete pairing surface as an invariant in CONTEXT.md: a channel's VST talks to that channel's extension **only**, because (1) plugin identity (UID + filename + display) is channel-forked here, and (2) **all** wire keys — `banks`, `assign_request`, S9's @@ -1117,7 +1122,7 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa isolation is structural — no per-key opt-in — so a future wire key that forgets to isolate is impossible by construction** (it keys under the namespace accessor, not a raw literal). This is the guard that S9/S8/S17's cross-artifact keys inherit isolation for free. -- [ ] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).** +- [x] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).** Both channels installed side-by-side: each browser sees only its channel's banks; a stable-project + beta-VST opens clean-empty (not error); the S-NAME-1 rename/rebind compat test extended to the beta UID (save a beta instance, confirm it rebinds by the beta UID on @@ -1140,11 +1145,11 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve engine.) -- **Hand-rolled Preserve pitch core (HELD — fork S16-F2 route b).** A pure `pitch_shift` - OLA/granular module (house pattern, CTest-testable) as the quality/latency upgrade for the - S16 Preserve engine if `WDL_SimplePitchShifter`'s SimpleWindowed warble or onset lag proves - musically unacceptable. Same `PitchEngine::Preserve` contract behind the seam. Held — the - build starts with route a (WDL) to prove Preserve at low cost. +- **WDL_SimplePitchShifter swap (HELD — fork S16-F2 route a).** `WDL_SimplePitchShifter` + as a drop-in swap for the `pitch_shift` pure module if the hand-rolled OLA onset latency + or warble proves musically unacceptable. Same `PitchEngine::Preserve` contract behind the + seam. WDL excluded from the shipped build by include-chain (windows.h); held as the + quality/latency alternative. - **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to *cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction. @@ -1163,10 +1168,14 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa exposed via `queryInterface` on the controller; inline strip drawn into REAPER's embed bitmap (LICE idiom, no HWND); `WM_GETMINMAXINFO` size hints confirmed; embed open/close/resize lifecycle clean. -- **VST3 bus arrangement (S7)** — `setBusArrangements` / `getBusArrangement` and REAPER's +- **VST3 bus arrangement (S7)** — ~~`setBusArrangements` / `getBusArrangement` and REAPER's mono/stereo instrument-bus expectations, against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus - re-negotiating cleanly. + re-negotiating cleanly.~~ **verified (S7):** `setBusArrangements` accepts only the mode's + arrangement (kMono/kStereo); `getBusArrangement` (base default) reports the valid mode; + a runtime mode change calls `restartComponent(kIoChanged)` so REAPER re-negotiates; + verified against the vendored Steinberg SDK (`ivstaudioprocessor.h`, + `vstsinglecomponenteffect.cpp` base impl, `ivsteditcontroller.h` kIoChanged). - **Media Explorer surface (S8)** — confirmed thin against the vendored headers: `OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the one last-played/selected file + range) are the whole contract; **no** enumerate-selected and @@ -1184,13 +1193,10 @@ the rule). Dispatchable in parallel with, or immediately after, the in-flight wa path) and `simple_pitchshift.h` (`WDL_SimplePitchShifter`, time-domain OLA, **duration- preserving** — the S16 Preserve-engine candidate, fork S16-F2 route a) are the **whole** pitch/resample surface; **no** elastique / formant-preserving in the vendored WDL tree. - **S16 Preserve-engine (route a) must-verify at build:** pre-warm `WDL_SimplePitchShifter` - at voice-allocation so `m_queue` reaches steady state (no `process`-thread `WDL_Queue::Add` - growth); measure the per-voice CPU + onset latency (window·srate) against the polyphony cap; - set the **Preserve-mode-specific voice cap** if the per-voice cost demands one. The pitch- - envelope modulation stays hand-rolled over whichever engine. If the held sinc Varispeed- - quality upgrade is ever taken, verify `WDL_Resampler` streaming/prealloc against per-voice - RT budget before use. + **S16 Preserve engine shipped as hand-rolled pure OLA (`pitch_shift` module); WDL excluded + by include-chain (windows.h), held as swap.** The pitch-envelope modulation is hand-rolled + over the engine. If the held sinc Varispeed-quality upgrade is ever taken, verify + `WDL_Resampler` streaming/prealloc against per-voice RT budget before use. - **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present** in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the negative-`instantiate`-always-adds semantics are documented in the header comment). (2)