From f0d5d231714e2bbec12478bd61727dacac6be9b8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 16:43:24 -0400 Subject: [PATCH 01/40] =?UTF-8?q?docs(q-w0):=20T1=20DSP=20audit=20?= =?UTF-8?q?=E2=80=94=2011=20triaged=20findings,=20SOLA=20engine=20sound;?= =?UTF-8?q?=20stereo=20splice=20decorrelation=20is=20the=20headline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/product/audit-notes/q-w0-t1-dsp.md | 279 ++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/product/audit-notes/q-w0-t1-dsp.md diff --git a/docs/product/audit-notes/q-w0-t1-dsp.md b/docs/product/audit-notes/q-w0-t1-dsp.md new file mode 100644 index 0000000..86f34d7 --- /dev/null +++ b/docs/product/audit-notes/q-w0-t1-dsp.md @@ -0,0 +1,279 @@ +# Q-W0 Track 1 — DSP / audio algorithm-quality audit (findings) + +Static analysis only; no code changed. Surfaces per the track brief: `src/vst/pitch_shift.{h,cpp}` +(highest priority — GA correlation-aligned SOLA rewrite + GA2 prime + GA3 freeze, never audited), +`src/vst/sampler_core.{h,cpp}`, `src/peaks.*`, `src/wav_trim.*`, the capture/tail paths +(`src/capture.cpp`, `src/capture_realtime.cpp`, `src/realtime_record.h`), `src/vst/master_gain.*`, +`src/vst/velocity_curve.*`. + +Dispositions follow Q-11 (SETTLED): default document-and-defer; a bounded SOLA fix is weighed +before any technique replacement; technique replacement is Daniel's decision at triage. Note on +"assigned wave": **no structural wave Q-W1..Q-W6 opens the `src/vst/` DSP files** (they target +`core/json`, `bank_panel`, `main`, `actions`, `persist`, registration tables) — so any DSP finding +triaged fix-now is remediated in Q-W0 itself or folded in as a new point before Q-W1 begins. + +I cannot listen; every artifact below is stated as mechanism + predicted audible consequence. +Perceptual materiality is Daniel's call. + +--- + +## Overall verdict on the pitch engine (Q-11 framing) + +The correlation-aligned SOLA in `pitch_shift` is **not a reinvented wheel in the pejorative +sense** — single-tap SOLA with normalized cross-correlation splice alignment, parabolic sub-sample +peak refinement, and amplitude-complementary raised-cosine fades *is* an established time-domain +technique family (SOLA/TD-PSOLA lineage), and the implementation is unusually well-defended: +normalized (not raw) correlation, ratio-scaled fade lengths with a drain-headroom derivation, +prime-with-real-content onset, frozen-writer tail, filled-span clamping, and double-before-int64 +clamps at the overflow-prone spots. The RT discipline holds throughout: `process()` does no +allocation, no locks; the splice burst is bounded and fires once per splice cadence, not per frame. +**No technique replacement (phase-vocoder / WSOLA) is warranted on this evidence.** The findings +below are bounded-fix candidates and documented limits within the existing approach, exactly the +Q-11 escalation ladder's first rung. + +--- + +## Findings + +### T1-01 — Stereo Preserve: per-channel independent splice alignment decorrelates L/R +- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` (Preserve branch, ~577–589) + + `src/vst/pitch_shift.cpp` `PitchShifter::splice`. +- **Mechanism:** a stereo Preserve voice owns two `PitchShifter`s, each running its **own** + correlation search on its own channel's PCM. `bestLag + frac` differ per channel at every splice, + so after the first splice the two read taps sit at different ring positions — an inter-channel + time offset of up to ±`maxLag` (= window/4 ≈ **12.5 ms** at the 50 ms window), re-drawn at every + splice (cadence ≈ window/|ratio−1| frames). The splice *schedules* also diverge (delay drift + depends on tap position), so L and R fade at different times. +- **Predicted artifact:** on genuinely stereo captures played through Preserve off-root: stereo + image wander / widening that changes at the splice cadence, and comb-filter coloration on any + mono sum. Correlated stereo content (the common case for a captured bus) is the worst case. + Dual-mono (mono sample in stereo bus) is unaffected — the code correctly mirrors one shifter. +- **Severity:** **High** (Preserve is the product-default pitch engine and the output bus is + permanently stereo with channel mode auto-defaulting from the capture — this hits the flagship + path on stereo material). +- **Disposition proposal:** this is the strongest candidate for a **bounded SOLA fix** (Q-11 rung + 1): link the channels — run the correlation search once (on the L+R mid signal, or on L as + master) and apply the same `bestLag + frac` and splice schedule to both channels. Standard + practice for stereo SOLA. It reshapes the `PitchShifter` seam slightly (splice decision must be + computable once and applied to two rings — e.g. a lag-provider hook or a two-channel shifter), + but no technique change and no new dependency. Recommend **fix-now in Q-W0** pending Daniel's + triage call; if deferred, record it as the known stereo-Preserve limitation. + +### T1-02 — Ratio slew mid-fade can drain the outgoing tap past the writer +- **Location:** `src/vst/pitch_shift.cpp` `splice()` fade-length cap (~301–311) + `process()` tap + advance (~360–366). +- **Mechanism:** `fadeLen_` is capped from the drain headroom **at splice time** using the + then-current `ratio_`. The pitch envelope legitimately slews the ratio per frame + (`setShiftRatio` mid-fade). A ratio that **rises** after the splice (pitch-env attack toward a + positive peak, or attack from a negative dip back to base) drains tap B faster than the cap + assumed; the code comment claims the 2-frame margin covers "any realistic per-frame bias", but + the margin is absolute, not slew-proportional: e.g. a splice at ratio ≈ 1 sets + `fadeLen_ = window/4` with no cap (drainRate ≈ 0), and a pitch-env attack ramping to +24 st + (ratio 4) within those ~12 ms drains tap B ≈ 3·window/4 — far past the `dLow` ≈ window/4 + headroom. Tap B laps the parked/advancing writer and reads ring-length-stale content at up to + ~half fade gain. +- **Predicted artifact:** a periodic click/garble burst at the splice cadence during fast upward + pitch-envelope ramps on Preserve voices. Only reachable with the AD pitch envelope enabled and + steep; base transpositions (constant ratio) are correctly covered by the existing cap. +- **Severity:** Med. +- **Disposition proposal:** document-and-defer (needs pitch-env + Preserve + steep attack to + trigger), with a cheap bounded fix noted for whenever the file is opened: re-tighten `fadeLen_` + when the ratio increases mid-fade (the `freezeTail()` re-anchor block is the exact pattern to + reuse), or clamp tap B's delay to ≥ 2 during a fade. + +### T1-03 — Preserve prime ignores the Trigger play-end bound (short-span rings hold cut content) +- **Location:** `src/vst/sampler_core.cpp` `Voice::start` prime block (~375–400) vs. the GA3 + `feedBound` in `advanceFrame` (~568–572). +- **Mechanism:** the per-frame feed treats `playEnd_` (Trigger) / `frameCount` as source + exhaustion and freezes the writer so "no padding ever enters the ring" (GA3). But `start()` + primes a **full window** from `pcm[q]` bounded only by `frameCount` — not by `playEnd_` — and + pads with zeros past the sample end while declaring the whole window `filled_`. Two + consequences for spans shorter than the 50 ms window: (a) a Trigger zone's ring holds real PCM + **past the user's chosen stop**, which an up-shifted tap can reach and play (transposed) before + `readPos_ ≥ playEnd_` frees the voice; (b) a sample shorter than the window gets zero padding + inside the ring as declared-valid history, so splices can land in silence — a bounded re-entry + of exactly the burst/gap onset artifact GA2/GA3 eliminated, scoped to sub-50 ms material (short + drum one-shots are realistic content). +- **Severity:** Med (bounded to short spans / short samples in Preserve; inaudible for spans ≥ one + window). +- **Disposition proposal:** bounded fix candidate — prime `min(window, span-to-feedBound)` frames + and call `freezeTail()` immediately after prime when the span is shorter than a window (the GA3 + machinery then recycles the real short tail, which is its designed behavior). Small and + contained in `Voice::start`. Recommend fix-now in Q-W0 if Daniel agrees the short-one-shot case + matters; else document-and-defer with this note as the record. + +### T1-04 — No sustain-loop crossfade (hard loop seam) +- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` loop wrap (~493–498, 610–612). +- **Mechanism:** the sustain loop wraps by subtracting the loop length (phase-preserving) and the + interpolation partner wraps `i1 → loop.start`, giving one-sample continuity only. There is no + crossfade region: unless the user's loop points sit at amplitude/slope-matched positions, every + loop pass produces a step discontinuity — a click at the loop rate. `waveform_view`'s + zero-crossing snap on the loop markers mitigates but does not remove it (zero crossings with + mismatched slopes still click). Established samplers crossfade the loop seam (equal-power over a + user- or fixed-length region). +- **Severity:** Med (musically prominent when it hits, fully user-avoidable with careful loop + placement). +- **Disposition proposal:** document-and-defer — a loop-crossfade is a *feature* (needs a + crossfade-length parameter and UI surface), not a bug fix; wrong scope for a reorg phase. Record + as a known limitation beside the zone-loop spec. + +### T1-05 — Linear interpolation + no band-limiting on repitch (both engines) +- **Location:** `src/vst/sampler_core.cpp` Varispeed read (~607–624); `src/vst/pitch_shift.cpp` + `readTap` (~172–185). +- **Mechanism:** all fractional reads are first-order (linear). Linear interpolation's frequency + response rolls off highs and leaks imaging sidebands (the interpolation image spectrum is + attenuated only ~12 dB/oct); Varispeed up-shifts additionally alias (reading faster than 1× with + no pre-filter folds source content above the post-shift Nyquist back into band). This is classic + hardware-sampler behavior — often accepted, sometimes desired — and both engines share it + consistently. +- **Severity:** Low (quality ceiling, not a defect; deterministic and stable). +- **Disposition proposal:** document-and-defer as a recorded trade-off. If a quality bump is ever + wanted, a 4-point cubic Hermite read is a drop-in bounded upgrade at both call sites (no + structural change); band-limited varispeed is a much bigger lift and not recommended. + +### T1-06 — Correlation search: coarse step 4 can mis-lock on very high fundamentals; maxLag bounds alignment to ≥ ~80 Hz +- **Location:** `src/vst/pitch_shift.cpp` `splice()` search loops (~240–257) and `configure()` + geometry (~68–72). +- **Mechanism:** two documented-by-construction limits. (a) The coarse search samples the + correlation every 4 lags and refines ±3 around the coarse best — full integer coverage only + *near* the coarse winner. For content whose correlation oscillates with period < ~8 samples + (fundamentals above ~5.5 kHz at 44.1k), the coarse grid can alias and lock a non-optimal region; + the splice then lands up to half a period misaligned. (b) `maxLag = window/4` (~12.5 ms) cannot + span a full period below ~80 Hz, so deep-bass fundamentals cannot be period-aligned and splices + degrade toward unaligned OLA there. Both are inherent range/cost trades every SOLA makes; the + in-code comment already states (b). +- **Severity:** Low (edge content: pure tones > 5 kHz, fundamentals < 80 Hz). +- **Disposition proposal:** document-and-defer; record both bounds as the engine's stated + operating range. No change recommended — widening either costs splice-burst CPU linearly. + +### T1-07 — `splice()` up-jump clamp comment contradicts the code (margin direction) +- **Location:** `src/vst/pitch_shift.cpp` ~196–210. +- **Mechanism:** the comment derives the "tight cap" as `filled_ - d - maxLag_ - 2`, then says the + code's `- 1` is "one sample of conservative margin" — but `-1` permits a *larger* jump than + `-2`, i.e. the code is *less* restrictive than the comment's own derivation; the sentence has + the direction backwards. Re-deriving: the deepest probe is the parabola's outer lag at + `d + jump + maxLag + 1` (the interpolator's `i1 = i0 + 1` read-ahead moves *younger*, not + deeper), so the code's `-1` is exactly tight and the comment's `-2` double-counts the + interpolator. No out-of-range read either way; the comment is wrong, not the code. +- **Severity:** Low (doc-only; misleads the next maintainer of a safety-critical clamp). +- **Disposition proposal:** fix-now (comment rewrite, zero behavior change) — fold into whichever + Q-W0 remediation touches `pitch_shift`; if none does, a standalone one-line doc fix in Q-W0. + +### T1-08 — Linear-in-amplitude ADSR decay/release segments +- **Location:** `src/vst/sampler_core.cpp` `AdsrEnvelope::tick` (~131–170). +- **Mechanism:** decay and release ramp linearly in amplitude. Constant-slope amplitude is + constant-dB-rate nowhere: a long release spends most of its wall-clock at perceptually loud + levels then collapses abruptly (in dB terms the curve is logarithmic-late). Classic samplers use + exponential (constant-ratio) segments for decay/release. The evaluator itself is correct and + well-tested (release-from-current-level, hold-0 byte-compat re-dispatch are both right). +- **Severity:** Low (character, not correctness; the perceptual judgment is Daniel's). +- **Disposition proposal:** document-and-defer. An exponential-segment option is a contained + evaluator change but alters every existing instrument's envelope feel — a product decision, not + a Q-W0 cleanup. + +### T1-09 — Takeover-declick: `declickR_` is dead state +- **Location:** `src/vst/sampler_core.cpp` (~598–599, 639–646, 515–518). +- **Mechanism:** both channels deliberately share one blend weight (`declickL_` — commented), but + `declickR_` is still seeded and decayed every frame and never read for output. Dead state that + invites a future L/R-weight divergence bug. The blend itself audits **clean**: `out' = + (1−w)·out + w·ref` is a convex combination for w ∈ [0,1], so `|out'| ≤ max(|out|,|ref|)` — the + rev-2 boundedness claim is mathematically sound, the boundary-frame identity holds, and the + ring-out path on voice end is handled (the peer-path symmetry is present). +- **Severity:** Low (hygiene; no audio effect). +- **Disposition proposal:** fix-now-trivial (delete the field or rename the shared weight) — fold + into any Q-W0 edit of `sampler_core`; not worth its own change otherwise. + +### T1-10 — `planWavTruncate` silently drops chunks located after `data` +- **Location:** `src/wav_trim.cpp` `planWavTruncate` (~151), `capture_realtime.cpp` + `trimAutoTailInPlace`. +- **Mechanism:** the plan truncates the file at `dataByteOffset + keptDataBytes`. Any RIFF chunk + REAPER wrote *after* the data chunk (bext/iXML/smpl orderings vary by writer) is discarded; the + RIFF size is patched consistently so the result is a valid WAV, but metadata is lost without a + trace. The PCM and the trim boundary math themselves audit clean (file-rate-authoritative frame + math, scan confined to the tail region, -72 dB threshold single-sourced from + `kAutoTrimThresholdDb`, one-frame-past-last-audible per spec, no-trim fallbacks total). +- **Severity:** Low (metadata only; audio unaffected; trim is a convenience path). +- **Disposition proposal:** document-and-defer — note the behavior in the header's FORMAT + ASSUMPTION block when the file is next touched. Preserving trailing chunks would complicate the + single-truncating-write design for no audio benefit. + +### T1-11 — `makeUniqueTag` has one-second resolution (collision window) +- **Location:** `src/capture.cpp` (~224–227) and `src/capture_realtime.cpp` (~120–123). +- **Mechanism:** the uniqueness tag is `std::time(nullptr)` — 1 s resolution. Two captures of the + same `baseName` within the same wall-clock second derive the same file stem: the offline path + would overwrite the first render's file and mint two Samples with colliding ids. Reachable in + practice via `batch_capture` driving several short renders back-to-back. The realtime path + can't self-collide (transport exclusivity) but shares the pattern. DSP-adjacent rather than + DSP; recorded here because the capture paths are this track's surface — Track 2 may claim it. +- **Severity:** Low-Med (silent data loss on collision; narrow window). +- **Disposition proposal:** fix-now candidate, trivial: append a per-session monotonic counter to + the tag (both call sites). Belongs wherever Track 2/triage routes capture-path hygiene; Q-W3 + (main/orchestration split) is the nearest wave that opens the extension capture flow, else Q-W0. + +--- + +## Surfaces that came back clean + +- **`src/peaks.*` — clean.** The bin partition `[b·frames/binCount, (b+1)·frames/binCount)` is + exact integer math, remainder-distributing, no dropped tail; overflow guarded; short-buffer + clamped; per-channel with no fold (invariant honored). `columnMinMax` mirrors the partition with + 64-bit products and the enclosing-bin fallback. `lastFrameAboveThreshold` scans backward with a + correct strictly-greater test and no wrap hazard. +- **`src/wav_trim.*` — clean** except T1-10 (metadata note). Chunk walk is bounds-checked and + total; even-byte padding honored; extensible-format float discrimination via the SubFormat GUID + leading tag is correct; LE reads via `memcpy` (no aliasing UB); truncate plan never grows. +- **`src/capture.cpp` (offline) — clean** from the algorithm-quality lens except T1-11. Exact + unrounded bounds, dither forced off (bit-identical repeats), float32-only with ground-truth + format blob, surgical trim-end normalize only in Auto, full snapshot/restore RAII. The + precision-invariant plumbing is disciplined. +- **`src/capture_realtime.cpp` + `src/realtime_record.h` — clean** except T1-11 (shared) and the + already-in-code DAW-verify flags (take/frame-0 alignment assumption for the trim; abort()'s + best-effort finalize racing the flush — both explicitly documented in place, correctly scoped). + The record state machine's decisions are pure and ceiling-bounded; the trim is best-effort and + never eats the range body. +- **`src/vst/master_gain.*` — clean.** Taper endpoints single-sourced; norm-0 true-zero detent + with the finite −60 dB floor; unity at ≈ 0.714 as documented; inverse collapses sub-floor values + to the detent (documented); non-finite input clamped. The dB↔linear math is correct. +- **`src/vst/velocity_curve.*` — clean.** The Fritsch–Carlson tangent is the standard + weighted-harmonic-mean form (w₁ = 2h₂ + h₁, w₂ = h₂ + 2h₁), which bounds m ≤ 3·min(d₁,d₂) — + monotonicity and no-overshoot inside [0,1] hold as claimed; sign-change/flat neighbors pin to 0; + zero-span steps and coincident-X knots are handled; deserialize repairs the invariant + defensively. `eval` once per note-on keeps it off the per-frame path. +- **`sampler_core` voice/steal/mono machinery — clean** (beyond the findings above): the steal + policy is deterministic and as documented; the mono held-stack has correct range guards against + uint8 aliasing, order-preserving removal, per-note velocity for retrigger fallback, and CC 123 + as its only reset path; `soundingNote()` correctly excludes ring-out tails from the Preserve cap + and legato predicates; the two-tier panic semantics are right; both render overloads share one + summation discipline with no allocation; envelope/keymap resolution honors the rate-free-seconds + invariant (frames resolved at keymap build against the live rate — the prior frame-domain + incident is not repeated here). The per-frame `std::pow` when the pitch envelope is active is + bounded and acceptable. +- **`pitch_shift` core machinery — clean** (beyond the findings above): the safe-band geometry, + filled-span clamps, normalized correlation, parabolic sub-sample refinement, complementary + raised-cosine fade (correct for phase-aligned content; the −6 dB midpoint on uncorrelated + content is a documented, benign trade), `freezeTail`'s fade re-anchor continuity, and the + down-shift ring-lap margin all audit sound. Down-shift writer-lap is unreachable for any ratio + above ≈ −109 st; up-shift fade drain is covered to +24 st and beyond by the ratio-scaled cap + (T1-02 is the slew case only). + +--- + +## Summary table + +| ID | Surface | Finding | Severity | Disposition proposal | +|-------|----------------------------------|------------------------------------------------------------|----------|---------------------------------------------------| +| T1-01 | pitch_shift + sampler_core | Stereo Preserve: independent L/R splice alignment | High | Bounded SOLA fix (linked lag) — recommend fix-now in Q-W0; Daniel's call | +| T1-02 | pitch_shift | Ratio slew mid-fade can lap the outgoing tap | Med | Document-and-defer; bounded re-cap noted | +| T1-03 | sampler_core (Preserve prime) | Prime ignores Trigger playEnd / pads short samples | Med | Bounded fix candidate in Q-W0; else defer w/ note | +| T1-04 | sampler_core (loop) | No sustain-loop crossfade (hard seam) | Med | Document-and-defer (feature, not reorg scope) | +| T1-05 | sampler_core + pitch_shift | Linear interp, no band-limiting on repitch | Low | Document-and-defer (recorded trade-off) | +| T1-06 | pitch_shift | Coarse-search HF mis-lock; ≥ ~80 Hz alignment bound | Low | Document-and-defer (stated operating range) | +| T1-07 | pitch_shift | maxJump clamp comment contradicts code | Low | Fix-now (comment-only), in Q-W0 | +| T1-08 | sampler_core (ADSR) | Linear-amplitude decay/release segments | Low | Document-and-defer (product decision) | +| T1-09 | sampler_core (declick) | `declickR_` dead state | Low | Fix-now-trivial, fold into any Q-W0 edit | +| T1-10 | wav_trim | Truncate drops post-`data` chunks (metadata) | Low | Document-and-defer (header note) | +| T1-11 | capture.cpp + capture_realtime | 1 s-resolution unique tag → batch collision window | Low-Med | Fix-now candidate (monotonic counter); route at triage | + +Clean surfaces: `peaks`, `master_gain`, `velocity_curve` (fully); `wav_trim`, offline + realtime +capture paths, and the non-flagged machinery of `sampler_core` / `pitch_shift` (clean with the +noted exceptions above). From dfe6ccbddd5bce8a16852d8c68367d6f1ed744c0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 16:43:34 -0400 Subject: [PATCH 02/40] =?UTF-8?q?docs(q-w0):=20T2=20architecture=20audit?= =?UTF-8?q?=20=E2=80=94=20wire-cursor=20hardening=20drift,=205th=20JSON=20?= =?UTF-8?q?decoder,=205x=20readFileBytes,=20rect-type=20zoo;=2011=20triage?= =?UTF-8?q?d=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audit-notes/q-w0-t2-architecture.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/product/audit-notes/q-w0-t2-architecture.md diff --git a/docs/product/audit-notes/q-w0-t2-architecture.md b/docs/product/audit-notes/q-w0-t2-architecture.md new file mode 100644 index 0000000..efea37c --- /dev/null +++ b/docs/product/audit-notes/q-w0-t2-architecture.md @@ -0,0 +1,333 @@ +# Q-W0 Track 2 — architecture-smell audit (functional lens) + +Static analysis of the whole `src/` tree (extension + `src/vst/`), 2026-07-28, branch +`pq-w0-audit`. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in +`docs/product/code-organization.md` — this track reports the **functional** smells those did not +target: duplicated *algorithms* (not merely duplicated responsibilities), reinvented wheels, +poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four +god-modules, the 4× JSON `Parser`, fat headers, `promptText`/`mintBankId` duplication, namespace +flatness, naming families) are **not restated**; where a finding below touches the same file it +is because the functional mechanism is new. + +Every claim below was verified by grep/read of the actual tree. Line numbers are as of this +audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6. + +--- + +## Findings + +### T2-01 — 3× copy-pasted length-prefixed wire `Cursor`, with security-hardening drift +**Location:** `src/provenance.cpp:56–137`, `src/assignment_request.cpp:25–121`, +`src/sample_usage.cpp:25–89` (plus a fourth sibling: `src/vst/bank_sync.cpp:11–28` +`parseBankGeneration` re-rolls the same guarded decimal accumulate). + +**Mechanism.** The `':'` ext-state wire idiom ("one grammar across every ext-state +seam", per sample_usage's own comment) is implemented as three near-identical `putField` + +`Cursor` copies — and they have **drifted on the hardening**. The two newer copies +(`assignment_request`, `sample_usage`) carry a 20-digit length cap and an overflow guard +(`len > (SIZE_MAX - digit) / 10 → fail`) plus the subtraction-first bounds check +(`len > s_.size() - start`). The oldest copy (`provenance.cpp:65–82`) has **neither**: a crafted +long digit run wraps `len` silently, and the additive bounds check `start + len > s_.size()` +can itself wrap, letting a wrapped length pass. Downstream, `parseFingerprint` +(`provenance.cpp:197–199`) calls `r.trackGuids.reserve(guidCount)` on an **unbounded** count +parsed by the equally unguarded `fieldSizeT` — a corrupt/crafted `Sample.provenance` string in +the bank JSON can drive `reserve(huge)` into `std::length_error`/`bad_alloc` thrown through the +shell. (`sample_usage` fixed exactly this with its `count > wire.size()/4 + 1` sanity bound, +`sample_usage.cpp:121`; the fix was never backported.) `std::string::assign` clamping keeps the +wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld +in two copies and eroded in the third — the textbook cost of a duplicated algorithm. + +**Severity:** High (the drift already produced a concrete robustness gap on a persisted, +user-editable input; the class of bug will recur with every new wire seam). +**Disposition:** **fix-now, split:** (a) backport the hardened `field()` + a count sanity bound +to `provenance.cpp` **in Q-W0** — small, pure, existing `provenance_tests` covers round-trip and +malformed-input paths; (b) the structural collapse (one shared `wire` codec module beside +`core/json`, all three seams + `parseBankGeneration` consuming it) belongs to **Q-W1**, which is +already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the +dedup is a relocation-adjacent move that should ride the wave already creating `core/`. + +### T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count +**Location:** `src/tail_control.cpp:88–130` (`valueAfterKey` + `deserializeTailSetting`). + +**Mechanism.** The catalogued DRY violation is "JSON `Parser` duplicated 4×" (`bank_model`, +`bank_book`, `view_mode_model`, `owned_manifest`). `tail_control` carries a fifth, structurally +different JSON decode: a substring-scan reader (`json.find("\"key\"")` → skip ws → parse token). +It is correct for the flat single-object payload it reads (the file argues this honestly), but +it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key +found inside a *string value* would match — impossible today only because the writer is its own +sole producer). If Q-W1 extracts `core/json` from the four `Parser`s and misses this site, the +"one JSON path" goal is silently not achieved. + +**Severity:** Med (no live bug; a completeness gap in the already-planned fix). +**Disposition:** **fix-now, folded into Q-W1** — add `tail_control` to the Q-W1 consumer list +explicitly. Rationale: zero extra cost when `core/json` lands; a stray fifth decoder afterward +would be a defect of the wave. + +### T2-03 — `readFileBytes` hand-rolled five times, both sides of the artifact split +**Location:** `src/capture.cpp:232`, `src/capture_realtime.cpp:329` (as `readAllBytes`), +`src/ingest.cpp:86` (comment admits: "of capture.cpp's readFileBytes"), +`src/vst/reasampler_processor.cpp:72`, and inline in `src/vst/reasampler_editor.cpp:749–756`. + +**Mechanism.** The identical ifstream-binary-ate/tellg/read whole-file loader exists five times +(two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies +already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence +will be behavioral (e.g. one copy gaining a size ceiling the others lack). + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W1** — a trivial pure `readFileBytes` helper in the +`core/` utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a +ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's +job — doing it days earlier in the flat tree would just move the file twice. + +### T2-04 — the growing `GetProjExtState` read loop, three copies, pure half only half-used +**Location:** `src/persist.cpp:140–157` (`getProjExtStateString`), +`src/vst/reaper_bridge.cpp:93–107` (self-described "mirrors persist.cpp's growing strategy"), +`src/usage_scan.cpp:168–181` (self-described "the persist.cpp idiom"). + +**Mechanism.** The grow-buffer-until-it-fits retry loop over `GetProjExtState` is implemented +three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int +return against the filled buffer — is *already extracted pure* as +`bridge_marshal::decodeGetProjExtState`, but only `reaper_bridge` consumes it; `persist` and +`usage_scan` interpret `rv` inline with their own conventions (persist: `rv <= 0` → absent; +bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely +the kind of edge that drifts when defined thrice. `usage_scan`'s copy is prune-safety-adjacent +(an unreadable usage record must abort the prune) — its read loop deserves the tested pure +decode, not an inline reimplementation. + +**Severity:** Med. +**Disposition:** **fix-now, assigned to the downstream wave that opens `persist`** +(Q-W4 per the current wave map; whichever wave splits `persist.cpp` is the moment). Generalize +the retry policy (next-capacity/done decision) into `bridge_marshal` (or its `core/` successor) +and make all three loops consume it. Rationale: touching persist's session machinery outside +its own wave risks the highest-traffic shell for a dedup that has no live bug today. + +### T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family +**Location (structs):** `action_bar.h:61`, `bank_grid.h:23`, `card_drag.h:109`, +`component_geometry.h:28,53,106`, `drag_out.h:40`, `footer_bar.h:41`, `mode_switch.h:21,35`, +`overflow_menu.h:23,39`, `prune_button.h:32,46`, `tab_strip.h:24,51`, `tooltip.h:20`, +`vst/editor_geometry.h:19`, `vst/velocity_curve.h:124`. +**Location (predicates):** inline half-open `px >= r.x && px < r.x + r.width && …` re-typed in +`action_bar.cpp`, `bank_grid.cpp`, `card_drag.cpp` (×2), `component_geometry.cpp` (×2), +`drag_out.cpp`, `footer_bar.cpp`, `mode_switch.cpp`, `overflow_menu.cpp`, `prune_button.cpp`, +`tab_strip.cpp` (×2), `bank_panel.cpp:1049`, plus `vst/editor_geometry.cpp:20`. + +**Mechanism.** §2b.2 catalogued the *naming/collision* half of this (the `footer_bar.h` "NAME +NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical +axis-aligned `{x, y, w, h}` record types, each with its own hand-typed containment predicate. +Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical +concrete implementations that one shared type collapses at compile time — one `ui::Rect` + one +`contains(Rect, x, y)` free function (both already exist in embryo as `vst/editor_geometry`'s +`Rect`/`contains`), with per-module aliases or thin wrappers only where a struct carries extra +fields (e.g. `TabRect::index`). Zero runtime cost; deletes ~15 chances for the next half-open/ +closed-interval inconsistency to slip in. + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W2** (the ui/ relocation wave) — collapsing the type +zoo is nearly free precisely when every one of these files is being moved and re-namespaced; +doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the +stated principle for renames (§2b intro); it holds identically for type unification. + +### T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap) +**Location:** `src/vst/reasampler_editor.cpp` — `SampleFaceLayout` (~line 922) and its builder, +`ClusterLayout` (~line 964), `zonesStripArea`/`noteEntryFieldsArea`/`noteEntryFieldRect`/ +`zonesControlPanel`/`zonesDeckArea`/`zonesCurveButton` (lines 992–1042), the channel-toggle +segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry +computations across the 3,065-LOC TU. + +**Mechanism.** The codebase's own grammar homes exactly this class of math in pure modules +(`editor_geometry`, `knob_deck`, `curve_popup`, `capture_browser`, …), yet the editor shell has +accreted a second, untested layout layer: whole named layout structs and pure `Rect → Rect` +functions that take only ints and rects, compiled into the one TU that cannot be unit-tested +without a host window. This is the mirrored form of the pure/shell leak (algorithm math living +untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue +covered the extension tree only — `reasampler_editor.cpp` (3,065 LOC) and +`reasampler_processor.cpp` (1,164 LOC) repeat the bank_panel pattern on the VST side and appear +in no existing finding. + +**Severity:** Med (no correctness bug found in the stranded math; the cost is untestability and +the growth trajectory — the editor gained ~500 LOC/phase through r11). +**Disposition:** **document-and-defer, with a named reshape:** Q-W0 should surface a downstream +point (the wave that opens `src/vst/`, or a new one) hoisting the Sample-face/Zone-panel layout +into the existing pure homes (`editor_geometry` is the natural owner). Rationale: a hoist is a +behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg; +but it must be a recorded point or the layer keeps growing. + +### T2-07 — the extension links the entire voice engine to serialize one preset blob +**Location:** `CMakeLists.txt:383–385` (`instrument_drop` → PUBLIC `sample_map`); +`src/instrument_drop.cpp` includes `vst/sample_map.h`, which pulls `sampler_core.h` → +`pitch_shift.h` + `velocity_curve.h`. + +**Mechanism.** `instrument_drop` (extension side) deliberately reuses +`sample_map::serializeComponentState` so the `.vstpreset` payload and the instrument's own +reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer +lives *inside* the module that also owns zone resolution, WAV decode plumbing, and (via header +fan-in) the whole voice engine — so `reaper_reasampler` compiles and links `sampler_core`, +`pitch_shift`, and `velocity_curve` object code it never executes. The abstraction is right; its +*granularity* is wrong: the ComponentState codec is not separable from the engine stack today. + +**Severity:** Low (dead weight in the binary and a misleading dependency edge; no runtime cost — +heuristic (c) is about call chains, which this does not add). +**Disposition:** **document-and-defer to Q-W1/Q-W2 module-homing:** when serialization gets its +`core/` home, split a `component_state` codec module (types + serialize/deserialize only) out of +`sample_map`; both artifacts link the codec, only the VST links the engine. Rationale: purely +structural, zero behavior change, and exactly the kind of module-boundary decision the reorg +waves exist to make once, deliberately. + +### T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers +**Location:** `src/wav_trim.cpp` (canonical parse: `parseWavLayout`/`extractFloatFrames`), +`src/capture_paths.cpp:31–115` (a second, independent RIFF chunk walker for content hashing), +`src/ingest.cpp:108–160` (hand-built 32f WAV writer), `src/capture_realtime.cpp:423` (in-place +RIFF/data size patch). + +**Mechanism.** The tree is disciplined about *decoding* ("no third WAV reader" — sample_map, +editor, processor all route through `wav_trim`), but RIFF *container* knowledge is still minted +per site: `capture_paths` walks chunks with its own tag/size/pad-byte logic to hash `fmt `+`data` +while skipping metadata; `wav_trim` walks the same container shape for layout; `ingest` writes +headers by hand; `capture_realtime` patches sizes by offset. Four places know the RIFF framing +rules (even-byte padding, chunk-header arithmetic); a drift in any one (e.g. pad-byte handling) +would desynchronize hashing from decoding — the dedup-by-hash and null-test invariants both sit +on this. + +**Severity:** Low (all four are currently correct against each other by inspection; the smell is +the maintenance surface, not a live divergence). +**Disposition:** **document-and-defer** — consolidate into a `core/wav` home (walker + layout + +writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns +the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates +`wav_trim` is the natural moment. + +### T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences +**Location:** `src/capture.cpp:490–537` vs `src/capture_realtime.cpp:505–540`. + +**Mechanism.** The finished-capture metadata stamp — `trackGuids`, `channelCount`, `sampleRate`, +`Master_GetTempo`, the `TimeMap_GetTimeSigAtTime` block, the WAV-aware `hashWavContent` content +hash (comment block duplicated verbatim, ~10 lines), `createdTimestamp` — is written twice, once +per backend. The copies have already diverged in quiet ways: offline passes `proj = nullptr` +(active project) to `TimeMap_GetTimeSigAtTime` while realtime pins `st.proj_`; the sampleRate +fallback logic differs in shape; realtime overrides `lengthSeconds` post-hoc. Some divergence is +semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field +(e.g. a new provenance stamp) must currently be added in two places, and the time-sig +active-project vs pinned-project asymmetry is exactly the kind of drift that produces a +wrong-project stamp during a background-project capture. + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W3** (the wave already hoisting capture orchestration +out of `main.cpp` / right-sizing `capture.h`). Extract a `stampCaptureSample(Sample&, const +CaptureRequest&, ReaProject*)` shared helper; the divergent bits (length override) stay in the +realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it +there keeps one review of the precision-invariant-adjacent code. + +### T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted +**Location:** `src/bank_panel.cpp:422–483` (extension: `computeThumbnail` + pure +`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs +`src/vst/reasampler_editor.cpp:715–789` (VST: `monoPcmFor` keyed by bare `sampleId`, +`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale +`clear()` at lines 159–160/894). + +**Mechanism.** The dock panel and the VST browser render the same thumbnails through the shared +`peaks::computeEnvelope`, but the caching layer around it was re-designed independently on each +side: the extension bakes the bank generation into a *pure, tested* key type; the editor +hand-concats a string key with no generation and relies on call-site `clear()`s (bank-refresh, +resize). Both are correct **today** — but correctness on the editor side is distributed across +remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount > +frameCount…") is duplicated verbatim in both TUs (`bank_panel.cpp:462`, +`reasampler_editor.cpp:780`), marking the copied design. A future refresh path that forgets the +clear shows stale waveforms with no test to catch it. + +**Severity:** Low. +**Disposition:** **document-and-defer** — when T2-06's layout hoist opens the editor, adopt the +pure `ThumbnailKey` (or a shared `thumb_cache` helper) on the VST side. Rationale: no live bug; +unifying cache policy is a natural rider on the editor wave, pointless as standalone churn. + +### T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read +**Location:** `src/vst/sample_map.cpp:775–945` (`deserializeComponentState`). + +**Mechanism.** Audited the full lift chain for functional soundness: the bounded `ByteReader` +latches on truncation, every version's tail fields carry per-field corrupt fallbacks +(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed +prefix), and the strict-prefix envelope discipline is honest. **No correctness finding.** The +smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id +→ zones read sequence that the v6+ shared path also implements (three near-copies of the same +cursor walk, lines 806–841 vs 851+), and each new envelope version adds another +`version >= kꞏꞏꞏV*Version` stanza to a function already ~170 lines long. + +**Severity:** Low. +**Disposition:** **document-and-defer, explicitly.** The legacy branches are frozen back-compat +contract code with saved-project blobs as their only callers; rewriting them into a table-driven +lift risks the one thing they must never break, for zero user-visible gain. Record the pattern +so the *next* envelope bump (v12) prefers extending the shared path over minting another branch. +(The unbounded-suffix version-constant naming is §2b territory; not restated.) + +--- + +## Surfaces checked and found clean + +Recorded per the wave's no-silent-omission rule; each was read/grepped this audit. + +- **Pure-module include hygiene, both trees.** Every module CLAUDE.md claims pure was scanned + for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean, `src/` and `src/vst/` both. The one + grep hit in `pitch_shift.h` is a comment (the S16 WDL-exclusion note), not an include. The one + cross-tree include (`instrument_drop` → `vst/sample_map.h`) is pure-to-pure — see T2-07 for + the granularity concern; it is not a boundary violation. +- **`bank_sync`** — the generation/consume decision rules are pure, explicit, and exhaustively + commented (rules 1–4); `parseBankGeneration` is overflow-guarded (its duplication is rolled + into T2-01's family, not a separate defect). +- **`bridge_marshal`** — one honest job, done pure, with the S1 string-scan JSON reader + documented as retired (verified: no second JSON parser on the VST side; `sample_map` routes + through `BankBook::deserialize`). +- **The realtime record lifecycle** — *not* an implicit state machine: `RecordPhase` is an + explicit enum with pure per-tick transitions in `realtime_record.h`; `main.cpp` holds only the + handle + project pointer. (Its *residence* in main.cpp is catalogued §2.1; nothing functional + to add.) +- **Project-identity transitions** — `classifyProjectTransition` is pure (capture_paths), the + shell passes `sameProjectObject` as a bool to keep it so; the GUID-primary layering is + decision-tabled in one place. +- **`usage_scan`** — every decision delegated to pure `sample_usage`; container recursion is + depth-bounded with a protect-on-truncation fail-safe; the `std::function` parm-getter + indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain). +- **Exception boundaries** — the three `catch (...)` sites (`bank_book.cpp:794` stol guard, + `instrument_drop_win.cpp:74` REAPER-callback boundary, `render_settings.cpp:180` stod guard) + are all documented, narrow, and non-swallowing in intent (each converts to an explicit + failure value). No silent error swallowing found. +- **`FxBypassGuard` (main.cpp) vs `view.cpp` park/restore** — both are snapshot-mutate-restore + over track flags and *look* like a dedup candidate; they are deliberately not one. Different + flag sets, different invariants (precision-neutralization vs Design-View parking), different + failure postures. Duplication of shape, not of concept — correctly left separate. +- **Path resolution** — `capture_paths::resolveBankFile` is the single resolver on both sides + of the split (panel, insert, drag_out, editor, processor). No parallel path logic. +- **WAV decode on the play path** — `wav_trim` is genuinely the only decoder (T2-08 concerns + the *container* knowledge spread, not a second decoder). +- **Draw layer** — the VST editor/embed compile the same `draw_kit`/`theme`/`component_geometry` + the extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the + VST side. +- **Interface cost audit (heuristic (c))** — `ICaptureBackend` is the tree's only virtual + interface; two real implementations, dispatched once per capture (cold). No hot-path virtual + or std::function chain found in `sampler_core`/`pitch_shift`/`sample_map` (all static calls). + No interface-with-one-implementation found anywhere. +- **Boolean-parameter proliferation** — swept `src/` headers for multi-bool signatures; the only + hit is `bank_grid::applyClick(…, bool ctrl, bool shift, …)`, which mirrors physical modifier + keys and reads fine at call sites. Not a finding. + +## Cross-checks against the §2/§2b audits (gaps noted, not restated) + +- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree + is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued + (`reasampler_editor.cpp` 3,065 LOC, `reasampler_processor.cpp` 1,164 LOC) — carried here as + T2-06's scope note so the reorg waves size the `src/vst/` work realistically. +- §2.1's "4× JSON Parser" undercounts by one — T2-02 (`tail_control`). +- §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05. + +## Summary table + +| ID | Finding | Severity | Disposition | Where | +|-------|---------------------------------------------------------------|----------|-------------------|--------------| +| T2-01 | Wire `Cursor` ×3 with hardening drift; provenance unguarded | High | fix-now (split) | Q-W0 backport + Q-W1 dedup | +| T2-02 | Fifth JSON decoder in `tail_control` | Med | fix-now | Q-W1 | +| T2-03 | `readFileBytes` ×5 across both artifacts | Med | fix-now | Q-W1 | +| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) | +| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 | +| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave | +| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing | +| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing | +| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 | +| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave | +| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) | From ca086f40093a4c0f6212fdca19127608cfff3b1b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 16:50:38 -0400 Subject: [PATCH 03/40] =?UTF-8?q?docs(q-w0):=20T3=20env-coupled-constant?= =?UTF-8?q?=20audit=20=E2=80=94=207=20findings=20(2=20fix-now:=20gain-ramp?= =?UTF-8?q?=20step,=20fade-ceiling=20frames);=20persistence=20surfaces=20c?= =?UTF-8?q?lean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audit-notes/q-w0-t3-env-constants.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/product/audit-notes/q-w0-t3-env-constants.md diff --git a/docs/product/audit-notes/q-w0-t3-env-constants.md b/docs/product/audit-notes/q-w0-t3-env-constants.md new file mode 100644 index 0000000..993aa15 --- /dev/null +++ b/docs/product/audit-notes/q-w0-t3-env-constants.md @@ -0,0 +1,220 @@ +# Q-W0 Track 3 — env-coupled-constant domain-modeling audit + +Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an +environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be +stored **rate-free / device-free and resolved at the point of use** (PLAN.md §Q-W0 env-coupled +bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds +invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar +applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can +actually change under it. Frame counts computed transiently from seconds at the use site are +correct and are not reported. + +Waves referenced for disposition: Q-W1..Q-W6 open `bank_panel.cpp`, `main.cpp`, `actions.cpp`, +`persist.cpp`, and relocate the clean pure libs — **no downstream wave opens +`reasampler_processor.cpp` / `sampler_core.h` / `reasampler_editor.cpp` for logic change**, so +fix-now findings in those files must be remediated in Q-W0 itself. + +--- + +## Findings + +### T3-01 — master-gain ramp step is a frame-domain constant anchored to 48 kHz + +- **Location:** `src/vst/reasampler_processor.cpp:46-51` (`kGainRampRate = 1.0f / 960.0f`, + `kGainRampSnap`), applied per-sample at `:1069-1085` (stereo) and `:1114-1126` (mono). +- **Stored vs. correct domain:** stored as a **per-sample linear step** — `960` is literally + 20 ms × 48 000 Hz, and the comment says so ("960 samples @ 48 kHz ≈ 20 ms"). The intended + quantity is a **wall-clock ramp time** (~20 ms); the correct model is a seconds/ms constant + with the per-sample step derived from `sampleRate_` at `setupProcessing` — exactly the + pattern the same file already uses two paragraphs away for `kPreserveWindowMs` + (`:618-623`, `:733-735`). +- **What breaks when the environment shifts:** the ramp's wall-clock length halves at 96 kHz + (~10 ms) and quarters at 192 kHz (~5 ms); at 44.1 kHz it stretches to ~21.8 ms. The FB1 + "no zipper" contract degrades silently as the host rate rises. Not persisted, so no on-disk + breakage — but it is a live hardcoded-rate assumption in `src/`, which Daniel's standing + ruling forbids. +- **Severity:** Med. +- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: trivial, isolated, + behavior-identical at 48 kHz; no downstream wave opens this file, so deferring means keeping + a named violation of the no-hardcoded-rate ruling through the whole reorg. Store + `kGainRampSeconds = 0.020`, derive the step from the live rate where `sampleRate_` is set. + +### T3-02 — takeover-declick decay is a per-frame coefficient (documented deliberate) + +- **Location:** `src/vst/sampler_core.h:409-410` (`kDeclickDecay = 0.95`, + `kDeclickFloor = 1e-4`), applied per output frame in `sampler_core.cpp:514-519`, `:643-645`. +- **Stored vs. correct domain:** a per-output-frame exponential coefficient; the implied + wall-clock decay-to-floor is ~4.2 ms at 44.1 kHz and ~1.9 ms at 96 kHz. The strict + domain-model form would be a time constant in seconds resolved to a coefficient at engine + build. +- **What breaks when the environment shifts:** the takeover-blend residue fades ~2× faster at + 96 kHz. Audibly negligible for a declick micro-ramp — and the in-code comment + (`sampler_core.h:397-401`) **already documents this as a deliberate per-frame DSP micro-ramp, + "not a stored wall-clock quantity"**, with the 44.1–96 kHz variance stated and accepted. +- **Severity:** Low. +- **Disposition:** **document-and-defer.** Rationale: the coupling is already an explicit, + written, bounded design decision in the code; converting it buys no audible improvement. + Triage should ratify the in-code note as the record. + +### T3-03 — Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames + +- **Location:** `src/vst/reasampler_editor.cpp:395` + (`constexpr double kFadeMaxFrames = 88200.0`), used by `controlValue`/the commit path to + normalize the Trigger fade-in/out knobs. +- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in + the zones payload) is settled and correct — a source-timeline fact, invariant under project- + rate change (PLAN.md §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second + max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived + literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk. +- **What breaks when the environment shifts:** the environment here is the **source file's + rate**: a 96 kHz capture's maximum fade throw is ~0.92 s; a 22.05 kHz file gets 4 s. The knob's + full-scale meaning silently varies per loaded sample. +- **Severity:** Low (UI-only, not persisted, comment flags it as a "build-time residual — one + place to retune"). +- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: small and contained — replace + with `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate at the two normalize + sites (the editor already threads `frameCount + rate` through the pack/unpack path, + `envelope_edit.cpp:148`); storage domain unchanged. If triage prefers zero UI-feel change, + the fallback is document-and-defer with the comment amended to name the 44.1 k assumption. + +### T3-04 — drop-hint banner duration stored in sync-timer ticks + +- **Location:** `src/vst/reasampler_editor.cpp:2920-2923` (`dropHintTicks_ = 6`), decayed in + `onSyncTimer` (`:244-245`); field at `reasampler_editor.h:457`. +- **Stored vs. correct domain:** a wall-clock intent ("a few seconds of banner") stored as a + **count of `kSyncTimerIntervalMs` ticks** (6 × 500 ms). Correct model: a duration in ms, + ticks derived — or a `GetTickCount`-style deadline like the bank panel's tooltip already + uses. +- **What breaks when the environment shifts:** retuning the sync cadence (a plausible perf + tweak — the 500 ms value is itself a tuning constant) silently changes the banner duration. + The comment does state the coupling. +- **Severity:** Low. +- **Disposition:** **document-and-defer.** Rationale: cosmetic, self-documenting at the single + site, and the cadence and hint decay live three lines apart; a fix is fine to fold in + opportunistically if the file is ever opened, but does not justify a Q-W0 edit on its own. + +### T3-05 — systemic: no DPI/content-scale support in either UI surface + +- **Location:** systemic. VST3 editor: `reasampler_editor.cpp:150-153` (`ViewRect(0,0,840,620)` + default and the size floor at `:816`), all `editor_geometry` / `knob_deck` / `curve_popup` / + `envelope_overlay` px constants (e.g. the 8 px node min-separation, the 28×28 curve button), + cached font sizes in `draw_kit`. Extension side: the LICE-drawn `bank_panel` dock and its + geometry modules. No implementation of VST3's `IPlugViewContentScaleSupport` anywhere in + `src/vst/` (grep: zero hits for content-scale/DPI), no scale factor threaded through the + pure geometry modules. +- **Stored vs. correct domain:** every layout constant is a **physical device pixel** that + silently assumes ~96 DPI. Correct model: logical units × one scale factor resolved at draw + time (the pure geometry modules take widths/heights as parameters already, so a scale factor + threads through cleanly — the constants are centralized, which is the good news). +- **What breaks when the environment shifts:** on a 150–200 % Windows display the editor and + dock render physically small (or get bitmap-stretched by the host, blurring text); hit + targets like the 8 px min node separation shrink below comfortable pointer accuracy. + Usability, not correctness — nothing mis-plays and nothing persisted is wrong. +- **Severity:** Med (usability on modern displays; Windows-only product makes high-DPI common). +- **Disposition:** **document-and-defer.** Rationale: a proper UI-scaling pass is a feature + wave of its own (scale plumbing through ~15 geometry modules + font cache + both shells), + far outside Q-W0's remediation budget; deferral should be recorded as a named future phase, + and Q-W1's relocation of the geometry modules should keep the constants centralized so the + eventual scale factor lands in one place. + +### T3-06 — legacy v3 zone-payload lift divides by the *current* project rate + +- **Location:** `src/vst/sample_map.cpp:601-605` (v3 lift inside `readZonesPayload`), format + note at `sample_map.h:439-456`, `:510-513`. +- **Stored vs. correct domain:** the v3 blobs (Daniel's beta projects) stored wall-clock times + as frames — **the prior incident itself**. The lift converts frames → seconds by dividing by + the live `projectRate` threaded in at read time. That is exact only if the project rate today + equals the rate in effect when the S15/S16 editor wrote the frames; the write-era rate was + never recorded, so a project whose rate changed since lifts skewed times (old/new ratio, + e.g. ~8.8 % for 44.1→48 k). +- **What breaks when the environment shifts:** already broken by construction for + rate-changed-since-write projects; a one-time lift residue, after which v5+ re-saves in + seconds and the skew is frozen in, silently. +- **Severity:** Low (legacy-only, beta-project blobs, envelope-time magnitudes; unrecoverable + in principle — the missing datum was never written). +- **Disposition:** **document-and-defer.** Rationale: no better conversion exists; this is the + documented residue of the incident that motivated the seconds invariant. Worth one sentence + in the code-quality-audit report so the skew is a recorded known, not a mystery bug later. + +### T3-07 — SOLA correlation-segment cap of 512 frames (deliberate CPU bound; cross-ref T1) + +- **Location:** `src/vst/pitch_shift.cpp:72` + (`corrFrames_ = max(1, min(dLow_ - 1, 512))`; rationale comment at `:64-67`). +- **Stored vs. correct domain:** borderline by design. The quantity being bounded is **work per + splice** (multiply-accumulates), which is genuinely frame-domain — a CPU bound *should* be in + frames. The side effect is that the correlation segment's wall-clock span halves at 96 kHz + (512 frames ≈ 11.6 ms at 44.1 k, ≈ 5.3 ms at 96 k), raising the lowest frequency the + alignment search can lock onto at high rates. All other shifter geometry correctly derives + from `kPreserveWindowMs` resolved at the live rate. +- **What breaks when the environment shifts:** alignment quality for low-frequency content + degrades somewhat at high host rates; no correctness or persistence impact. +- **Severity:** Low. +- **Disposition:** **document-and-defer**, and hand to the T1 DSP audit for the quality call. + Rationale: the frame domain is arguably correct for a compute bound; whether 512 is the right + *number* is an algorithm-quality question (T1's territory), not a domain-modeling one. + +--- + +## Surfaces checked clean + +- **`sample_map` v5+ persistence (the reference implementation):** AHDSR + pitch-env times as + SECONDS doubles; no rate constant anywhere in the read/write paths (`kLegacyV3NominalRate` + deliberately does not exist); legacy v3 lift takes the rate as a parameter. Clean. +- **ComponentState envelope v6–v11 fields:** channel mode, assign generation, preview velocity, + voice count/mode/trigger, `masterGainLinear` (dimensionless linear), explicit flag, + `SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or + file-fact domains. Clean. +- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as + int64 SOURCE frames:** deliberate, settled source-timeline facts (PLAN.md §S15; + `bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under + project-rate change; the file's own rate is stored alongside and resolved at decode. Correct + domain, not a finding. +- **`trigger_seam`:** frames↔fraction with `startFrame` threaded both directions; the overlay's + fraction domain is expressly rate-invariant. Clean. +- **`kPreserveWindowMs` (50 ms):** resolved to frames against the live host rate at both call + sites (`reasampler_processor.cpp:618-623`, `:733-735`) — the correct pattern, cited here as + the model T3-01 should copy. +- **`pitch_shift` internal geometry:** ring length, fade, lag band, delay band all derived from + the rate-resolved `window_`; ratio-scaled live fade length. Clean (T3-07 cap noted above). +- **Tail system:** `TailSetting.manualMs` persisted in **ms**; `kMaxTailSeconds`/`kMaxTailMs` + wall-clock; trim threshold in **dB** with the linear ratio derived + (`render_settings.h:59-86`); the realtime decay scan resolves frames against the **file's own + authoritative rate** (`capture_realtime.cpp:384-408`). Clean. +- **`wav_trim`:** frame counts are parsed file facts and transient truncate plans. Clean. +- **`bank_model` persisted metadata:** source bounds in seconds + PPQ (both stored, each for + its consumer); loudness in dB; `sampleRate`/`channelCount` are *recorded facts about the + file*, not assumptions; capture tempo + meter stamped at capture time deliberately so the + bars.beats read-out is stable under later project meter changes (`card_meta`). Clean. +- **Ext-state wires** (`banks` JSON, view-mode model, owned manifest, `assignment_request`, + `rsusage_*`): no frame-domain values; the rate field in the assign wire is a recorded fact. + Clean. +- **Envelope schematic (`envelope_overlay`/`envelope_edit`):** param-domain px↔seconds scale + derived from the live rect (`gatePxPerSecond`), sample-length-free; editor time-slider + ceiling is `kEnvTimeMaxSeconds = 2.0` (seconds). Clean (pixel constants themselves fall under + the systemic T3-05). +- **Timers:** bank_panel tooltip delay uses `GetTickCount()` ms against `kTooltipDelayMs = 500` + (wall-clock — the pattern T3-04 should copy); editor sync timer is a 500 ms `SetTimer` + interval (ms, not ticks); the new-content detector is an event diff per tick with no + wall-clock meaning encoded in tick counts; `retireIdleDrain` is idleness-driven, not + time-driven. Clean. +- **`peaks` / `waveform_view` / `master_gain` / `velocity_curve` / `keyboard_strip`:** bins and + columns derived from rects at use; dB↔linear taper and curve math dimensionless; key rects + from the passed strip rect. Clean. + +## Summary + +| ID | Location | Stored domain | Severity | Disposition | +|----|----------|---------------|----------|-------------| +| T3-01 | `reasampler_processor.cpp:46-51` gain-ramp step | per-sample step (20 ms @ 48 k baked in) | Med | **Fix-now (Q-W0)** — store seconds, derive step from `sampleRate_` | +| T3-02 | `sampler_core.h:409-410` declick decay | per-frame coefficient | Low | Document-and-defer — deliberate, already documented in-code | +| T3-03 | `reasampler_editor.cpp:395` fade throw ceiling | 88200 source frames (2 s @ 44.1 k) | Low | **Fix-now (Q-W0)** — seconds ceiling resolved vs. source rate at use | +| T3-04 | `reasampler_editor.cpp:2923` drop-hint duration | sync-timer ticks | Low | Document-and-defer — cosmetic, coupling stated in-code | +| T3-05 | systemic (both UI surfaces) | physical px, ~96 DPI assumed; no content-scale | Med | Document-and-defer — a UI-scaling phase of its own; keep geometry constants centralized through Q-W1 | +| T3-06 | `sample_map.cpp:601-605` v3 legacy lift | frames ÷ *current* project rate | Low | Document-and-defer — unrecoverable legacy residue; record as known skew | +| T3-07 | `pitch_shift.cpp:72` correlation cap | 512 frames (CPU bound) | Low | Document-and-defer — frame domain arguably correct for a compute bound; hand to T1 for the quality call | + +Two fix-now findings (T3-01, T3-03), both assigned to **Q-W0 itself** — no downstream wave +opens those files for logic change. Five deferrals, each with a recorded rationale. The +persistence surfaces — the highest-stakes case — are clean: every wall-clock quantity written +to disk since the S12 remediation is in seconds or ms, and every frame-domain persisted value +is a source-file fact whose rate travels with it. From 2dbefb8c0162bb35a1bd32048171292e2b9cb02f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 16:50:47 -0400 Subject: [PATCH 04/40] =?UTF-8?q?audit(q-w0-t4):=20sizing=20census=20?= =?UTF-8?q?=E2=80=94=2014=20oversize=20files,=20VST=20side=20unowned;=20pr?= =?UTF-8?q?opose=20Q-W2v=20wave,=208-seam=20bank=5Fpanel,=20ICaptureBacken?= =?UTF-8?q?d=20is=20dead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/product/audit-notes/q-w0-t4-sizing.md | 399 +++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 docs/product/audit-notes/q-w0-t4-sizing.md diff --git a/docs/product/audit-notes/q-w0-t4-sizing.md b/docs/product/audit-notes/q-w0-t4-sizing.md new file mode 100644 index 0000000..9634422 --- /dev/null +++ b/docs/product/audit-notes/q-w0-t4-sizing.md @@ -0,0 +1,399 @@ +# Q-W0 Track 4 — structural sizing + placement audit + +Date: 2026-07-28 · Branch: `pq-w0-audit` · READ-ONLY static analysis (no build, no code edits) + +**Method.** Line counts measured with `wc -l` on the worktree; seams derived from function-definition +skeletons (`grep` for top-level definitions + section markers) plus targeted reads. Acceptance bar = +Daniel's three heuristics: (a) more directories a must, files ≤ ~600 lines, SRP applies to files and +namespaces; (b) templates are good where they dedup at compile time; (c) saved CPU beats abstraction — +no dispatch-stack blowouts, prefer static polymorphism where types are compile-time-known. + +**Measured sizes differ from the wave brief in several places** (the tree moved after the brief was +drafted — GA/pS/pS-usage landed): `reasampler_editor.cpp` 3065 (brief said 3035), +`reasampler_processor.cpp` 1164 (1004), `sampler_core.cpp` 968 (1049), `sample_map.cpp` 970 (807), +`sample_map.h` 708 (600), `sampler_core.h` 762 (820), `actions.cpp` 1016 (996), `persist.cpp` 852 +(812). All numbers below are the measured ones. + +--- + +## 1. Oversize census + seams + +Every `.cpp`/`.h` in `src/` (both sides) over ~600 lines, with the *real* responsibility clusters. +Where a file is genuinely one responsibility, I say so and recommend leaving it. + +### 1.1 The four planned splits — do the plan's seams still land sub-600? + +**T4-01 — `src/bank_panel.cpp` (3459; plan assumed 2424).** +The plan's six seams (`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / +`panel_bank_ops` / `panel_window`) no longer all land sub-600 at current size. Tally against the +skeleton: + +| Planned TU | Functions (line spans) | Est. LOC | Verdict | +|---|---|---|---| +| `panel_thumbnails` | `computeThumbnail`/`thumbnailFor` (420–487) | ~130 | fine | +| `panel_render` | `drawCardMeta`/`drawThumbnail` (487–547), kit adapters (547–570), `drawFooter` (685–780), `drawToolbar`/`drawMoreButton`/`drawTooltip` (992–1155), `drawRegionGrid`/`drawCardDropTarget`/`drawRegionHeader`/`drawTabStrip`/`paintPanel` (1334–1646) | ~700 | **over — needs the layout cut below** | +| *(unplanned)* **`panel_layout`** | toolbar/footer/menu rect + row/cluster builders (571–684, 785–991), split geometry + region rects + L7 slot-order display bridge (1156–1333) | ~500 | **new TU required** — this is pure-ish geometry glue, distinct from LICE drawing; extracting it puts `panel_render` at ~550 | +| `panel_audition` | `initPreview`/`startAudition`/`stopAudition`/`deinitPreview` (1874–1965) | ~90 | fine (keep the direct call-through guardrail) | +| `panel_input` | input helpers + `regionAt` (1965–2010), click routing `handleBanksChromeClick`…`handleKey`/accelerator (2384–2710), plus new-content detection (1647–1874, ~230) | ~800 | **over — needs the drag cut below** | +| *(unplanned)* **`panel_drag`** | `updateDropTarget`/`dropTargetBankId`/`classifyCardDrag`/`applyDragCursor`/`resolveHover`/`updateHover`/`maybeShowTooltip`/`onMouseMove`/`doReorderDrop`/`doReplaceDrop`/`resetDragState`/`onLBtnUp`/`handleRightClick` (2711–3185) | ~475 | **new TU required** — the card-drag/hover state machine is a cohesive cluster of its own (it already has a pure mirror, `card_drag`); extracting it puts `panel_input` at ~550 | +| `panel_bank_ops` | `promptText`/`mintBankId`/`doCreateBank`…`removeSamples`/`focusedSelectionIds`/`resolveDragPathsForOs` (2006–2231) + popup menus (2231–2384) | ~375 | fine (menus ride with bank_ops or input — either works; they invoke the ops) | +| `panel_window` | `handleDropFiles`/`dlgProc`/`openPanel`/`closePanel` (3185–3336) + public API (3336–3459) | ~275 | fine | + +**Proposal:** eight TUs, not six — add `panel_layout` and `panel_drag`. +**Severity:** high (it is the biggest file in the repo). **Disposition: reshapes wave Q-W2** — +the wave brief must name eight seams, or two of its six TUs ship >600 on day one. + +**T4-02 — `src/main.cpp` (1897; plan assumed 1762).** +The plan's three hoists are the right seams, but `capture_orchestrator` as specced lands **~885 +lines** — over by half again. Tally: `FxBypassGuard` (627–731, ~105), `renderOffline` + +`captureAndIndexOne` + `RunCapture` + `RunCaptureItemAssign` (731–909, ~180), batch family +(`ItemSelectionGuard`/`selectOnlyItem`/`RunBatchCaptureItems`/`collectRazorAreas`/ +`TrackSelectionGuard`/`RunBatchCaptureRazor`, 909–1181, ~270), `RunRecaptureFromSource` (1200–1398, +~200), realtime + insert actions (1398–1512, ~115). `scope_resolve` (361–590) ≈ 230 ✓; +`realtime_lifecycle` (186–360) ≈ 175 ✓; registration/entry residue (1512–1897) ≈ 385 ✓ (shrinks +further under Q-W6's table). +**Proposal:** split the orchestrator seam once more: `capture_orchestrator` (FxBypassGuard + +single-capture path + realtime/insert action bodies, ~450) and **`capture_batch`** (batch family + +`RunRecaptureFromSource` + the two selection guards, ~470). Recapture is planner-driven like batch +and shares the selection-guard machinery — it belongs with batch, not the single-shot path. +**Severity:** high. **Disposition: reshapes wave Q-W3** — add the fourth TU to the brief. + +**T4-03 — `src/actions.cpp` (1016; plan assumed 981).** +Plan's seams still land: `design_view_actions` (59–421, ~360), `bank_actions` (422–1016 minus prune, +~490), `prune_action` (`doBankPruneFolder` 821–916 + registration share, ~130). All sub-600. +**Disposition: no change to Q-W4.** + +**T4-04 — `src/persist.cpp` (852; plan assumed 766).** +Plan's seams still land: `ext_state_io` (helpers + `saveToActiveProject` + `writeAssignmentRequest`, +112–288, ~180), `prune_fs` (`scanPruneOrphans`/`pruneDryRun`/`pruneOrphanSet`/`deleteOrphanFile`/ +`pruneReclaim`, 288–539, ~250 — pS-usage growth landed here, exactly where the plan isolates it), +`session` (load/guid/poll/reload, 539–852, ~315). All sub-600. +**Disposition: no change to Q-W5.** + +### 1.2 Known offenders beyond the planned four — extension side + +**T4-05 — `src/bank_book.cpp` (1109) + `bank_book.h` (457).** +Three genuine seams: **`SlotMap`** (27–143, ~115 — a self-contained ordered-slot container with its +own serialize at 614), **`BankBook`** registry/CRUD/transfer/slot-reconcile (145–545, ~400), and +**JSON serialize + `Parser`** (547–1109, ~560). Q-W1 deletes the Parser + `ObjWriter`/`writeEscaped` +copies; what remains of serialization rewired onto `core/json` is ~150. +**Proposal:** after Q-W1, split `slot_map` into its own TU/header pair (it is a distinct type with +its own tests-worthy invariants); `bank_book.cpp` lands ~550. **Severity:** medium. +**Disposition:** fold into Q-W1 (the JSON rewire already opens this file; the `slot_map` file split +is one `git mv`-shaped extraction on top). + +**T4-06 — `src/view_mode_model.cpp` (1049) + `view_mode_model.h` (748).** +Four seams: **indexes** (`ModeRegistry`/`MembershipIndex`/`LaneOwnershipIndex`, 29–140), **pure +planners** (`autoTagNewContent`/`planItemRetag`/`planLaneMinting`/`makeParkPlan`/`makeRestorePlan`/ +`nextModeId`, 143–326, ~185), **`ViewModeModel`** state + visibility/toggle planning (332–490), and +**JSON serialize + `Parser`** (494–1049, ~555). Q-W1 deletes the Parser (~390); remainder ~660. +**Proposal:** split planners (`view_plan.cpp`) from model+indexes (`view_mode_model.cpp`, ~450 after +JSON extraction). The header's 26 structs split the same way: mode/membership/lane types + model +class vs. the plan-record structs (`TrackPlan`/`TogglePlan`/`AutoTag`/`ItemRetagOp`/`LaneMint*`). +**Severity:** medium. **Disposition:** fold into Q-W1 (JSON rewire opens the file; planner split +rides it). If the wave wants to stay minimal, the planner split can defer — post-extraction ~660 is +marginal, not pathological. + +**T4-07 — `src/bank_model.cpp` (767).** +Two seams only: the model (`Sample` equality + `BankIndex` verbs, 25–145, ~120) and JSON +(`ObjWriter`/`writeSample`/`Parser::parseSample`/`parseIndex`, 148–767, ~620). This file is the +poster child for Q-W1: after the extraction it is ~250 total (model + thin serialize using +`core/json`). **Disposition: already owned by Q-W1; no new seam needed.** + +**T4-08 — `src/capture_realtime.cpp` (867).** +Two seams: the **async record lifecycle** (`RealtimeCaptureState` snapshot/restore, `begin`/`tick`/ +`abort`, 177–324 + 569–867, ~450) and the **file-side finalize** (`readAllBytes`/`writeU32LE`/ +`trimAutoTailInPlace`/`finalizeRecording`, 324–566, ~240). The lifecycle is genuinely one +responsibility (the header itself documents why the restore lives on the state object). The finalize +half — WAV byte-patching, decay-scan trim, move-into-bank — is a distinct concern that talks to +`wav_trim`, not to the transport. +**Proposal:** split `capture_realtime_finalize.cpp` (~240); lifecycle TU lands ~600 with the file +banner. Both stay in `shell/capture/`. **Severity:** low-medium. **Disposition:** ride Q-W3 (the +wave already renames this family per the Q-9 naming rider — same-wave file surgery is free). + +**T4-09 — `src/view.cpp` (677).** +Two halves: **park/restore + flag application** (`snapshotTrack`/`applyFlags`/`parkFxOffline`/ +`restoreFxOffline`/`applyMode`, ~350) and **lane management** (`laneName`/`managedLaneOrdinals`/ +`applyLanePlays`/`applyLaneOps`/`readLaneTracks`/`assignItemToLane`/`applyMintPlan`/ +`mintManagedLanes`/`reconcileManagedLanes`, ~330). The D2 lane machinery arrived after the file's +original charter and is a separable concern. +**Proposal:** split `view_lanes.cpp`. **Severity:** low (677 is barely over). **Disposition:** +document-and-defer unless Q-W1's relocation is already touching it — then take the free split. + +**T4-10 — `src/ingest.cpp` (636).** +Seams: **pure-ish WAV/PCM helpers** (`buildFloat32Wav`/`decodePcmSource` + byte I/O, 86–217, ~130 — +see T4-22: `buildFloat32Wav` is a pure function trapped in a shell TU), **`importFileIntoActiveBank`** +(249–420, ~170), and the three ingest surfaces + registration (420–636). Extracting the pure WAV +build into a testable core module (`wav_write` beside `wav_trim`, or one `wav_codec`) drops the shell +to ~500 and gains a test target. +**Severity:** low-medium. **Disposition:** fix in whichever wave lands the WAV consolidation +(T4-22); the file split itself is a rider. + +### 1.3 Known offenders — VST side (entirely absent from the current plan) + +**T4-11 — `src/vst/reasampler_editor.cpp` (3065) + `reasampler_editor.h` (539).** +The single biggest unplanned file — it grew past `main.cpp` in the r11 recomposition, *after* the +plan was written. It is now the `bank_panel.cpp` of the VST artifact, with the same god-module +profile. Real seams, from the skeleton: + +| Proposed TU | Functions (line spans) | Est. LOC | +|---|---|---| +| `editor_session` | ctor/dtor, `refreshFromBank`/`rebuildVisible`/`onSyncTimer`/`commitAndReload`/`loadSelection`/`upsertPickedOverride`/effective-zone helpers (146–400), PCM + thumbnail caches `monoPcmFor`/`thumbnailFor` (719–800) | ~420 | +| `editor_controls` | control-value map `controlValue`/`applyControl` (399–475), knob-deck plumbing `zoneDeckGroupDescs`/`deckGroupDescs`/`deckControlNorm`/`applyDeckKnob`/`deckValueLabel` (475–649), envelope pack/unpack + `commitPickedMarkers` (649–719), `applyZoneControl` (2576–2587) | ~470 | +| `editor_layout` (pure candidate — see T4-23) | anon-ns geometry: `computeSampleBands`/`clusterRects`/zone-panel areas/`channelToggleRects` (904–1076), `computeBrowseModal` (1756–1785), `zoneContentArea` (1917–1924) | ~250 | +| `editor_paint_sample` | `paint` dispatch (1169), `drawTitleBand`, `paintSample`/`paintEnvelopeOverlay`/`paintVelocityCurve`/`paintKnobDeck`/`paintCurveButton`/`paintCurvePopup`/`paintEmptyState` + `drawKnobFace`/`drawSpectralStrip`/`drawRootMarker` (1076–1756) | ~590 | +| `editor_paint_browse_zone` | `paintBrowse` (1785–1917), `paintZone` (1924–2041) | ~260 | +| `editor_input` | `resolveHover` (2041–2145), `onMouseDown` (2147–2576 — a 430-line per-face dispatch), popup/curve mouse (1637–1735), `onMouseMove`/`onMouseUp`/`onMouseRDown`/`onMouseWheel`/`onSearchChar`/`onFilesDropped` (2587–2929) | ~880 → **split by face**: `editor_input_sample` (~500: sample-face hit branches + drag state + curve popup) and `editor_input_browse_zone` (~380: browser cards/scroll/search + zone strip/note entry) | +| `editor_platform` | IPlugView overrides `isPlatformTypeSupported`/`canResize`/`checkSizeConstraint`/`attachedToParent`/`removedFromParent`/`onSize`/`invalidate` (800–904), `wndProc` + non-Windows stubs (2929–3065) | ~280 | + +Seven-to-eight TUs, all sub-600, each a real cohesive cluster (session/bridge state · param +plumbing · layout · paint × 2 · input × 2 · platform). The face structure (Sample / Browse / Zone) +is the natural input/paint split axis — it mirrors how the code already dispatches. +**Severity: highest of the audit** — this is the largest unowned file in the repo. +**Disposition: reshapes the wave plan — needs a new owning wave** (see §1.5 / summary table). + +**T4-12 — `src/vst/reasampler_processor.cpp` (1164) + `reasampler_processor.h` (502).** +Four seams: **VST3 lifecycle + bus boilerplate** (`queryInterface`…`setupProcessing`, +`setBusArrangements`, 118–209 + 492–506, ~120), **component-state I/O** (`setState`/`getState` + +`legacyLiftShouldRun`, 209–349 + 774–904, ~270), **accessors/param setters** (349–492, ~140), and +**reload + drain + usage-publish + render** (`reloadInstrument`/`publishUsage`/ +`publishBuiltLocked`/`rebuildVoiceEngine`/`retireIdleDrain` 506–774 ~270, `process` 904–1164 ~260). +**Proposal:** three TUs — `processor_state` (state I/O + accessors, ~410), `processor_reload` +(reload/drain/usage, ~290), `reasampler_processor.cpp` (lifecycle + `process()`, ~400). Keep +`process()` and its block-render helpers in one TU (heuristic c — see T4-27). All are member +functions of one class; partial-class-across-TUs is the same pattern the Q-W2 panel split uses. +**Severity:** medium-high. **Disposition:** new VST wave (see §1.5). + +**T4-13 — `src/vst/sample_map.cpp` (970) + `sample_map.h` (708).** +Two clean halves plus a small third: **resolution core** (bank-JSON distill/select/list, SampleRefs +management, PCM downmix/extract/decode, `resolvePlay`, keymap builders, performance-map resolution + +`reconcileSingleCaptureZones`, 14–406, ~390) and **binary component-state codec** (`putU32le`/ +`putU64le`/`ByteReader`, zones payload put/read, `serializePerformance`/`deserializePerformance`, +`serializeComponentState`/`deserializeComponentState` v3→v11 lift ladder, selection codec, +406–970, ~565). +**Proposal:** split `component_state_io.cpp` (the codec, ~565) from `sample_map.cpp` (resolution, +~400). The header splits the same way: wire/state structs (`ComponentState`/`SampleRefEntry`/codec +decls) vs. resolution API (`SelectedSample`/`PerformanceMap`/`ZonePlaySeconds`/resolvers). This is +the same shape as the extension's model-vs-JSON split and directly reduces rebuild fan-out — the +editor and processor both include `sample_map.h` today and recompile on every codec tweak. +**Severity:** medium-high (the codec grows every ComponentState version bump — v6→v11 in one +quarter; it will cross 600 on its own soon). **Disposition:** new VST wave. + +**T4-14 — `src/vst/sampler_core.cpp` (968) + `sampler_core.h` (762).** +Contents: pitch math (15–30), `Keymap` (34–58), `AdsrEnvelope`/`TriggerEnvelope`/`PitchEnvelope` +(62–251, ~190), `Voice` (255–680 — `advanceFrame` alone is ~200), `VoiceEngine` (680–968, ~290). +**This TU is a genuine single responsibility — the realtime voice engine — and it is the hottest +code in the repo.** Every function in it sits on the per-sample render path; the envelope `tick()`s +and `Voice::advanceFrame` benefit from same-TU inlining (no LTO assumption in the build). Splitting +the .cpp along class lines would put per-sample calls across TU boundaries — precisely the +heuristic-(c) violation the phase forbids. +**Proposal:** **leave the TU whole at 968** (documented exception to the 600 bar, justified by the +hot path), but **split the header**, which is where the pain actually is: `zone_params.h` (the enums ++ `AdsrParams`/`TriggerParams`/`PitchEnvParams`/`ZonePlayParams`/`SampleLoop`/`SampleData` — what +`sample_map`, the editor, and the codec actually need, ~250) vs. `sampler_core.h` (Keymap + the +engine classes, ~500). Today every UI TU that reads a param struct recompiles when a `Voice` member +changes. **Severity:** medium. **Disposition:** header split in the new VST wave; TU stays — +recommend recording the exception in the wave brief so nobody "fixes" it later. + +**T4-15 — `src/view_mode_model.h` (748)** — covered under T4-06 (splits with its TU). +**T4-16 — `src/vst/sample_map.h` (708)** — covered under T4-13. +**T4-17 — `src/vst/sampler_core.h` (762)** — covered under T4-14. + +### 1.4 Borderline (no action, for the record) + +`capture.cpp` (549), `reasampler_editor.h` (539 — shrinks when the editor splits move private +helpers into their TUs), `reasampler_processor.h` (502 — same), `bank_book.h` (457), `pitch_shift.cpp` +(371 — single responsibility, hot, leave), `persist.h`/`capture.h` (341/234 — Q-W6 fat-header pass +already owns them). None need action beyond what their TU splits imply. + +### 1.5 The structural conclusion for the wave plan + +The existing plan splits 4 files, all extension-side. The census says **9 files need splitting and 2 +need header-only splits — 5 of them VST-side, which currently have no owning wave.** The VST work is +the same kind and size as Q-W2 (the editor alone ≈ the old bank_panel). Recommendation: add one VST +god-module wave (call it **Q-W2v**, runnable in parallel with Q-W2 — different artifact, zero file +overlap; or sequence after W5 as Q-W7 if Daniel wants serial waves). Q-W1's relocation scope also +grows: the ~20 clean VST pure libs relocate + namespace in W1 alongside the extension's 30. + +--- + +## 2. `src/vst/` placement in the Q-3 directory map + +The settled Q-3 map (`core/{model,view,capture,audio,ui,reclaim,version,json}`, +`shell/{capture,panel,view,persist,actions}`, `app/`) covers only the extension. Two viable shapes +for the VST artifact; **this is Daniel's fork to call at triage.** + +**T4-18 — Leading recommendation: integrate into the same `core/`/`shell/` top split, with +`instrument/` subsystem dirs beneath.** + +``` +core/instrument/engine/ sampler_core, pitch_shift, velocity_curve, master_gain +core/instrument/map/ sample_map (+component_state_io), bank_sync, bridge_marshal, note_entry, trigger_seam +core/instrument/ui/ editor_geometry, keyboard_strip, waveform_view, capture_browser, + browser_scroll, param_slider, knob_deck, curve_popup, + envelope_overlay, envelope_edit, embed_strip +shell/instrument/ reaper_bridge, processor TUs, editor TUs, reasampler_embed, vst_entry, + reasampler_vst.h / reasampler_uid.h +``` + +Rationale: +1. **One rule, no special case.** Q-3's settled reasoning is "top-level by the load-bearing + discipline, because the pure/shell split is the invariant worth making structural." That reasoning + is artifact-agnostic — a file's directory should tell you whether it may touch a *host* type + (REAPER or VST3 SDK), and `shell/instrument/` says exactly that. +2. **The artifact boundary is a link-graph fact, not a source-layout fact — and the sources already + straddle it.** Verified cross-artifact consumers: `sample_map` links `bank_book` + `wav_trim` + + `sampler_core`; the editor includes `draw_kit`/`theme`/`component_geometry`/`capture_paths`/ + `peaks`/`wav_trim`/`app_version`/`ext_keys` (extension-side modules); the extension's pure + `instrument_drop` includes `vst/reasampler_uid.h`. An artifact-first subtree would either + duplicate these or still reach across — the boundary it draws is already false. +3. **Namespace map falls out:** `reasampler::instrument::{engine,map,ui}` beside + `reasampler::model` etc. — the Q-4 rule applied uniformly. +4. **CMake impact: path edits only.** Targets, links, and test executables are unchanged; the + VST3-gate (`EXISTS pluginfactory.cpp`) already guards targets, not directories. + +Cost to name: the VST3-gated targets stay interleaved through the top-level `CMakeLists.txt` rather +than being isolatable behind one `add_subdirectory`. Mitigable by grouping the instrument targets +into one guarded block (or one `include()`d .cmake file) without moving sources. + +**T4-19 — Alternative: parallel artifact-first subtree** — `src/vst/core/{engine,map,ui}` + +`src/vst/shell/`, extension keeps `src/core|shell|app`. Pros: the artifact boundary is visible at +top level; the whole VST tree (sources *and* a dedicated `src/vst/CMakeLists.txt`) can sit behind +one SDK-gated `add_subdirectory`, which is the cleanest possible expression of "this half only +exists on Windows with the submodule slice". Cons: two parallel `core/` trees dilute the "directory += may it touch a host type" invariant into "check which subtree first"; the shared-module reality +(point 2 above) means the subtree is not actually self-contained — its purity is cosmetic; and the +gated-`add_subdirectory` win is achievable under T4-18 with an `include()` anyway. **Recommend +T4-18; T4-19 is defensible if Daniel weighs artifact legibility above discipline uniformity.** +**Disposition: reshapes Q-W1** (the relocation wave executes whichever shape is chosen). + +--- + +## 3. Template-collapse opportunities + +Judged per heuristic (b) — proposed only where duplication is real and the template earns it; two +anti-recommendations included, because a forced template is the worse smell. + +**T4-20 — Little-endian byte codec: real template win.** +Five hand-rolled copies, verified: `putU32le`/`putU64le` + `ByteReader` (`vst/sample_map.cpp` +406–500), `writeU32LE` (`capture_realtime.cpp` 342), `readU32LE` lambda (`capture_paths.cpp` 49), +`putU32` lambda (`ingest.cpp` 134), `appendU32LE` (`instrument_drop.cpp` 18). One header — +`core/wire/bytes.h` (or beside `core/json`): `template void putLE(std::vector&, +T)` / `template bool readLE(ByteReader&, T&)` with the double↔bits helpers — replaces all +five, compile-time dispatched, zero runtime cost, and gives the ComponentState codec (T4-13) a +tested primitive. Entirely off hot paths (serialization/file I/O only). +**Severity:** medium (each new ComponentState version re-duplicates today). **Disposition:** fix in +the wave that lands `component_state_io` (the biggest consumer); consumers rewire opportunistically. + +**T4-21 — Rect family: unify, but with a concrete type, NOT a template.** +Verified 12+ byte-identical `{int x,y,width,height}` structs (`ActionBarRect`/`CellRect`/ +`PanelClientRect`/`FooterBarRect`/`HeaderRect`/`SegmentRect`/`MenuBarRect`/`MenuButtonRect`/ +`FooterRect`/`ButtonRect`/`TabStripRect`/`KitBox`…) plus a *second grammar* on the VST side +(`editor_geometry`'s `Rect` is LTRB with `left/top/right/bottom` + `height()`). The right tool is +one concrete `ui::Rect` + `contains()` with per-role type aliases (`using ButtonRect = ui::Rect;`) +so call sites keep their semantic names — Q-W1's "one `ui::` owner" note already points here; this +finding extends it: (a) retire the XYWH-vs-LTRB fork by picking one grammar (LTRB has the live +`contains`/`height` users; either works — pick once), and (b) the per-type `contains`/`hitTest*` +one-liners collapse for free. A template rect would model nothing — the types differ in name only. +**Watch:** cross-lib name collisions (extension `Rect` vs vst `Rect`) surface only when both headers +meet in one TU — `sample_map` and the editor are exactly such TUs; the Q-4 sub-namespaces are the fix. +**Severity:** medium. **Disposition:** ride Q-W1 (it is the settled `ui::` unification, widened to +include `editor_geometry::Rect`). + +**T4-22 — Linear rect-scan hit-tests: small template, real but modest.** +`hitTestCell` (`bank_grid`), `hitTestSlot` (`card_drag`), and the tab/segment scans are the same +first-rect-containing-point loop over records that carry a rect plus extra fields (`SlotCellRect` +adds `slot`). After T4-21, a single `template int hitIndex(int px, int py, +span)` (requiring `r.rect.contains(px,py)` or a rect accessor) collapses them. Earns its +keep only if T4-21 lands first; alone it would be a forced template. +**Severity:** low. **Disposition:** document-and-defer; opportunistic rider on Q-W1. + +**T4-23 — WAV build/parse consolidation (dedup, not template).** +`buildFloat32Wav` (ingest, 116–179) hand-writes the float32 header that `wav_trim` hand-parses and +`capture_paths`/`capture_realtime` chunk-scan/byte-patch. One pure `wav_codec` (or fold build into +`wav_trim`, renamed) gives one tested owner of the RIFF layout. Concrete functions; nothing to +template. **Severity:** low-medium. **Disposition:** fix-now-sized, but assign to the wave that +opens `ingest.cpp` (T4-10) to avoid a standalone churn commit. + +**T4-24 — `clamp01`: dedup with one inline, anti-template.** +Six verified copies (`envelope_overlay`, `master_gain`, `param_slider`, `reasampler_editor`, +`velocity_curve` + `envelope_edit`'s `clamp`). One `constexpr inline double clamp01(double)` in a +shared core header (or just `std::clamp` at call sites). Not a template candidate — `std::clamp` +already is one. **Severity:** trivial. **Disposition:** rider on Q-W1 relocation. + +**T4-25 — JSON `Parser`/`ObjWriter`/`writeEscaped`/`intToStr` ×4 — already owned by Q-W1; +confirmed still accurate** (verified in `bank_model`/`bank_book`/`view_mode_model`/ +`owned_manifest`; `sample_usage` uses its own `rsusage` k/v wire, *not* a fifth JSON parser — no +scope growth). Concrete class, not a template. **Disposition:** no change. + +--- + +## 4. Indirection audit (heuristic c) + +**T4-26 — `ICaptureBackend` is now a dead abstraction: one implementation, zero polymorphic call +sites. The brief's "two implementations — earning its keep" assumption is FALSE at current state.** +Verified: `capture.h` itself documents (lines 139–148, the "SEAM CHOICE" comment) that +`RealtimeRecordBackend` **deliberately does not implement** `ICaptureBackend` — it has a bespoke +async `begin/tick/abort` seam. `OfflineRenderBackend` is the sole deriver, and the only +construction site (`main.cpp:738`) instantiates the concrete type; nobody anywhere holds an +`ICaptureBackend*`/`&`. The interface costs a vtable and models nothing. +**Proposal:** delete `ICaptureBackend`; `OfflineRenderBackend` becomes a plain concrete class. Note +CLAUDE.md/CONTEXT still describe the module as "`ICaptureBackend` interface; two backends" — the doc +should be corrected in the same commit. **Severity:** low runtime, medium hygiene (it misleads — +this audit's own brief was misled). **Disposition:** fix in Q-W3 (the wave that rehomes the capture +orchestration and touches every call site). + +**T4-27 — Warning to the Q-W2v brief (T4-14): do not split `sampler_core.cpp` along class lines.** +`AdsrEnvelope::tick`/`TriggerEnvelope::amplitudeAt`/`PitchEnvelope::tick` are called per-voice +per-sample from `Voice::advanceFrame`, which is called per-sample from `VoiceEngine::render`. +Same-TU definition is what lets the compiler inline this stack today (no LTO configured). A +by-class TU split converts the hottest inner loop into cross-TU calls — the exact dispatch-stack +blowout heuristic (c) forbids. If a split is ever wanted, the envelopes must move as +header-defined (inline) classes, not to a TU. The engine TU staying whole at 968 is the correct +trade. + +**T4-28 — Warning to the Q-W2 brief (reaffirming the plan's own guardrail).** `panel_audition` and +the preview idle path must stay direct call-throughs after the 8-TU split — the plan already says +this; the two *added* TUs (T4-01: `panel_layout`, `panel_drag`) introduce no new risk (layout is +paint-time, drag is input-time), but the split of `onMouseMove` (which calls hover + drag + tooltip) +should keep per-mouse-move work as plain free-function calls, no interface. + +**T4-29 — Warning to the processor split (T4-12).** Keep `process()` and any per-block helpers it +calls in one TU. The state/reload/accessor TUs are UI-thread or setup-time — safe to move freely. +The atomic-pointer-swap pattern (`publishBuiltLocked`) must not gain a virtual seam. + +**T4-30 — No other gratuitous indirection found (verified, not assumed).** The only extension-side +`virtual` is T4-26. VST-side virtuals are all VST3-SDK-mandated overrides (`SingleComponentEffect`, +`CPluginView`, `IReaperUIEmbedInterface`) — not ours to remove. The layered pure→shell pairs +(`card_drag`→`bank_panel`, `realtime_record`→`capture_realtime`, `drag_out`→`drag_out_win`, +`prune_reconcile`→`persist`) are the load-bearing discipline, not forwarding waste — each layer +adds the decision/side-effect split, and all are direct calls. `bank_panel`'s ~20-function free-API +is a module boundary, not a dispatch chain; Q-W2's header segmentation thins it. + +--- + +## Summary table — every oversize file → proposed seams → owning wave + +| File (LOC) | Proposed TUs/headers | Owning wave | +|---|---|---| +| `bank_panel.cpp` (3459) | 8 TUs: render / **layout (new)** / thumbnails / audition / input / **drag (new)** / bank_ops / window | **Q-W2 (reshaped: 6→8 seams)** | +| `vst/reasampler_editor.cpp` (3065) | 8 TUs: session / controls / layout (pure candidate) / paint_sample / paint_browse_zone / input_sample / input_browse_zone / platform | **NEW wave Q-W2v** | +| `main.cpp` (1897) | orchestrator / **batch+recapture (new)** / scope_resolve / realtime_lifecycle / app-entry residue | **Q-W3 (reshaped: 3→4 hoists)** + Q-W6 | +| `vst/reasampler_processor.cpp` (1164) | processor_state / processor_reload / lifecycle+process (whole) | **NEW wave Q-W2v** | +| `bank_book.cpp` (1109) | slot_map / bank_book / JSON→`core/json` | Q-W1 (+slot_map rider) | +| `view_mode_model.cpp` (1049) + `.h` (748) | indexes+model / planners / JSON→`core/json`; header splits likewise | Q-W1 (+planner rider) | +| `actions.cpp` (1016) | design_view_actions / bank_actions / prune_action (unchanged) | Q-W4 (no change) | +| `vst/sample_map.cpp` (970) + `.h` (708) | sample_map (resolution) / component_state_io (codec); header splits likewise | **NEW wave Q-W2v** | +| `vst/sampler_core.cpp` (968) + `.h` (762) | **TU stays whole (hot-path exception, T4-27)**; header → zone_params.h + sampler_core.h | **NEW wave Q-W2v** (header only) | +| `capture_realtime.cpp` (867) | lifecycle / finalize | Q-W3 (rides the Q-9 rename) | +| `persist.cpp` (852) | session / ext_state_io / prune_fs (unchanged) | Q-W5 (no change) | +| `bank_model.cpp` (767) | model / JSON→`core/json` (unchanged) | Q-W1 (no change) | +| `view.cpp` (677) | apply / lanes | defer, or Q-W1 rider | +| `ingest.cpp` (636) | wav helpers→core / import / surfaces | wave owning T4-23 | + +**Wave-plan deltas requested of triage:** (1) Q-W2 grows to 8 seams; (2) Q-W3 grows to 4 hoists + +the `ICaptureBackend` deletion; (3) a **new VST god-module wave (Q-W2v)** owns the editor / +processor / sample_map splits + the sampler_core header split — parallel-safe with Q-W2 (zero file +overlap); (4) Q-W1's relocation scope includes the VST pure libs under the placement shape chosen +at the T4-18/T4-19 fork; (5) the `core/wire/bytes.h` LE-codec template (T4-20) lands with +`component_state_io`. From 3eaa0b886ebac78a4ec1ae68f318fc44bfc20753 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 17:00:06 -0400 Subject: [PATCH 05/40] =?UTF-8?q?docs(Q-W0):=20committed=20code-quality=20?= =?UTF-8?q?audit=20=E2=80=94=2059=20findings=20triaged,=20Q-W2v=20VST=20wa?= =?UTF-8?q?ve=20+=20Q-W2/Q-W3=20reshapes=20proposed,=20Daniel=20decision?= =?UTF-8?q?=20list=20for=20sign-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/product/code-quality-audit.md | 370 +++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 docs/product/code-quality-audit.md diff --git a/docs/product/code-quality-audit.md b/docs/product/code-quality-audit.md new file mode 100644 index 0000000..df59c85 --- /dev/null +++ b/docs/product/code-quality-audit.md @@ -0,0 +1,370 @@ +# ReaSampler code-quality audit — Q-W0 findings report and triage + +Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit. + +This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming +audit — `docs/product/code-organization.md` §2c.3; deliverable contract in PLAN.md §Q-W0). It +synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and +are the evidence base for every claim here — this report cites finding IDs and does not restate +mechanisms in full: + +- **Track 1 — DSP / audio algorithm quality:** [`audit-notes/q-w0-t1-dsp.md`](audit-notes/q-w0-t1-dsp.md) (T1-01…T1-11) +- **Track 2 — architecture smells (functional lens):** [`audit-notes/q-w0-t2-architecture.md`](audit-notes/q-w0-t2-architecture.md) (T2-01…T2-11) +- **Track 3 — env-coupled-constant domain modeling:** [`audit-notes/q-w0-t3-env-constants.md`](audit-notes/q-w0-t3-env-constants.md) (T3-01…T3-07) +- **Track 4 — structural sizing + placement:** [`audit-notes/q-w0-t4-sizing.md`](audit-notes/q-w0-t4-sizing.md) (T4-01…T4-30) + +Dispositions below are **proposals**. Per the Q-W0 sign-off gate, Q-W1 does not begin until Daniel +has signed off on every disposition; the open calls are collected in §4. + +--- + +## 1. Verdicts up front + +**DSP / pitch engine (the Q-11 question).** The correlation-aligned SOLA in `pitch_shift` is +**sound — no technique replacement (phase-vocoder / WSOLA) is warranted on this evidence** (T1 +overall verdict). Track 1 finds the implementation "unusually well-defended" (normalized +correlation, ratio-scaled fades with drain-headroom derivation, prime-with-real-content onset, +frozen-writer tail, filled-span clamping) with RT discipline intact throughout. Every T1 finding +sits on the Q-11 escalation ladder's first rungs — bounded fixes within the existing technique, or +documented operating limits — exactly the settled default. The one High finding (T1-01, stereo +splice decorrelation) is a bounded fix inside the current design (link the per-channel lag +search), not a technique change. Track 1 states plainly that it cannot listen: every artifact is +mechanism + predicted audible consequence, and perceptual materiality is Daniel's call. + +**Architecture (functional smells).** The load-bearing boundaries hold: no pure module includes a +host type, the VST bridge is genuinely read-only, WAV *decoding* has exactly one owner, the prune +and exception boundaries audit clean (T2 clean list). What Track 2 found instead is the classic +cost of duplicated *algorithms*: the length-prefixed wire `Cursor` copy-pasted 3× **with +security-hardening drift** — the oldest copy (`provenance`) missing the overflow guards its +siblings gained (T2-01, High, with a cheap Q-W0 backport); a fifth hand-rolled JSON decoder the +§2 audit did not count (T2-02); `readFileBytes` ×5 (T2-03); the ext-state grow-loop ×3 with its +pure decode half-adopted (T2-04); a 19-struct rect zoo (T2-05); and drifting copy-paste in the +capture-stamp epilogue (T2-09). All are dedup/relocation-shaped and route onto the reorg waves; +none is a live user-facing bug except the T2-01 robustness gap. + +**Env-coupled constants (the prior-incident category).** The persistence surfaces — the +highest-stakes case — are **clean**: every wall-clock quantity written to disk since the S12 +remediation is in seconds or ms, and every persisted frame-domain value is a source-file fact +whose rate travels with it (T3 clean list). Seven findings, only two fix-now: the master-gain +ramp step hard-codes 20 ms × 48 kHz (T3-01) and the Trigger-fade UI ceiling hard-codes +2 s × 44.1 kHz (T3-03) — both live hardcoded-rate residues in `src/`, both trivial, both in files +no then-planned downstream wave opens. The rest are deliberate-and-documented couplings or +recorded legacy residue, plus one systemic usability note (no DPI/content-scale support, T3-05) +that is a future phase of its own. + +**Sizing + placement.** The wave plan's four planned splits are necessary but no longer +sufficient: the census measures **9 files needing TU splits and 2 needing header-only splits — 5 +of them VST-side, which currently have no owning wave** (T4 §1.5). `reasampler_editor.cpp` +(3,065 LOC) is now the largest unowned file in the repo. Track 4's structural answer is a new +VST god-module wave (**Q-W2v**), reshaped seam lists for Q-W2 (6→8) and Q-W3 (3→4 hoists), one +documented exception (`sampler_core.cpp` stays whole — hot path, T4-14/T4-27), and one dead +abstraction to delete (`ICaptureBackend`, T4-26 — the brief's "two implementations" assumption is +false at current state). The `src/vst/` placement question is a genuine fork for Daniel +(T4-18/T4-19, §4a). + +--- + +## 2. Unified findings register + +Every finding from all four tracks, exactly once, with proposed disposition. Severities are the +tracks' own. "Q-W0" as a destination means remediated in this wave before it closes (post +sign-off). Cross-track overlaps are reconciled in §2.5. + +### 2.1 Track 1 — DSP (appendix: `q-w0-t1-dsp.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T1-01 | Stereo Preserve: per-channel independent splice alignment decorrelates L/R (image wander + mono-sum combing on stereo captures) | High | **Fix-now in Q-W0** (bounded SOLA fix: linked lag/schedule across channels) — **Daniel's call, §4b** | Hits the flagship path (Preserve default + permanently stereo bus); standard stereo-SOLA practice; no technique change | +| T1-02 | Ratio slew mid-fade can drain the outgoing tap past the writer (pitch-env attack case) | Med | Document-and-defer; bounded re-cap noted for when the file is next opened | Needs pitch-env + Preserve + steep attack to trigger; constant-ratio case already covered | +| T1-03 | Preserve prime ignores Trigger `playEnd_` bound; zero-pads sub-window samples as declared-valid ring content | Med | **Fix-now in Q-W0** (prime to feedBound + immediate `freezeTail()`) **if Daniel agrees short one-shots matter, else defer with note — §4b** | Small, contained in `Voice::start`; re-uses designed GA3 machinery | +| T1-04 | No sustain-loop crossfade — hard loop seam clicks unless loop points amplitude-matched | Med | Document-and-defer (record beside the zone-loop spec) | A crossfade is a feature (parameter + UI), wrong scope for a reorg phase | +| T1-05 | Linear interpolation + no band-limiting on repitch (both engines) | Low | Document-and-defer (recorded trade-off; cubic Hermite noted as drop-in if ever wanted) | Classic sampler behavior, deterministic and consistent across engines | +| T1-06 | Correlation search: coarse step-4 can mis-lock above ~5 kHz; maxLag bounds alignment to ≥ ~80 Hz | Low | Document-and-defer (record as the engine's stated operating range) | Inherent SOLA range/cost trades; widening costs splice-burst CPU linearly | +| T1-07 | `splice()` up-jump clamp comment contradicts the code (code is exactly tight; comment's margin direction is backwards) | Low | **Fix-now in Q-W0** (comment rewrite, zero behavior change) | Misleads the next maintainer of a safety-critical clamp; one line | +| T1-08 | Linear-in-amplitude ADSR decay/release (constant-dB nowhere; abrupt-late releases) | Low | Document-and-defer | Character-vs-correctness product decision; changing it alters every existing instrument's feel | +| T1-09 | `declickR_` is dead state (blend correctly shares one weight; R is seeded/decayed, never read) | Low | Fix-now-trivial **as a rider on any Q-W0 `sampler_core` edit** (T1-01/T1-03); else defer | Hygiene only, no audio effect; not worth a standalone change | +| T1-10 | `planWavTruncate` silently drops RIFF chunks after `data` (metadata loss on trim) | Low | Document-and-defer (note in the header's FORMAT ASSUMPTION block when next touched) | Metadata-only; preserving trailing chunks complicates the single-truncating-write design for no audio benefit | +| T1-11 | `makeUniqueTag` 1 s resolution → same-second batch captures collide (silent overwrite) | Low-Med | **Fix-now, assigned to Q-W3** (per-session monotonic counter, both call sites); Q-W0 fallback if triage prefers | T1 explicitly routes at triage; Q-W3 is the nearest wave opening the extension capture flow | + +### 2.2 Track 2 — architecture (appendix: `q-w0-t2-architecture.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T2-01 | Wire `Cursor` ×3 with hardening drift — `provenance` lacks the overflow/length guards its siblings have; unbounded `reserve` reachable from persisted input | High | **Fix-now, split:** (a) backport hardened `field()` + count sanity bound to `provenance.cpp` **in Q-W0** (§4c); (b) structural collapse to one shared wire codec **in Q-W1** | Hazard is cheap to close now with existing `provenance_tests`; the dedup should ride the wave already creating `core/` | +| T2-02 | Fifth hand-rolled JSON decoder in `tail_control` (the §2 "4× Parser" undercount) | Med | **Fix-now, folded into Q-W1** — add `tail_control` to the `core/json` consumer list explicitly | Zero extra cost when `core/json` lands; a stray fifth decoder afterward would be a defect of the wave | +| T2-03 | `readFileBytes` hand-rolled ×5 across both artifacts | Med | **Fix-now, folded into Q-W1** — one pure helper in the `core/` utility home; both targets link it | Five copies of a ten-line function; creating its home is exactly Q-W1's job | +| T2-04 | `GetProjExtState` grow-loop ×3; pure `bridge_marshal` decode only half-adopted (`usage_scan`'s copy is prune-safety-adjacent) | Med | **Fix-now, assigned to Q-W5** (the wave that splits `persist.cpp` — T2's own rule; its "Q-W4" label predates the plan's persist=W5 numbering) | Touching persist's session machinery outside its own wave risks the highest-traffic shell for a dedup with no live bug | +| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family | Med | **Fix-now, folded into Q-W1** — reconciled with T4-21 into one disposition, see §2.5(1) | Same-moment-as-relocation principle; PLAN Q-W1 already owns the `ui::` rect unification | +| T2-06 | Pure-computable layout math stranded in the VST editor shell (~49 inline geometry computations; §2's VST scope gap) | Med | Document-and-defer **with named reshape — satisfied by Q-W2v's `editor_layout` pure-candidate TU** (see §2.5(4), §3) | Behavior-preserving hoist best done under the reorg's test discipline; must be a recorded point or the layer keeps growing | +| T2-07 | Extension links the entire voice engine to serialize one preset blob (codec not separable from engine) | Low | Document-and-defer → **executed by Q-W2v's `component_state_io` split** (same split as T4-13, see §2.5(3)) | Right abstraction, wrong granularity; a module-homing decision the reorg waves exist to make once | +| T2-08 | WAV/RIFF container knowledge in 4 modules / 2 chunk walkers (dedup-by-hash + null-test invariants sit on their agreement) | Low | Document-and-defer → **consolidation moment is a triage question, §4e** (T2 prefers the `core/wav` homing moment; T4-23 prefers the wave that opens `ingest.cpp`) | All four currently correct against each other; pre-reorg consolidation churns the capture hot path for no functional gain | +| T2-09 | Capture backends' Sample-stamping epilogue copy-paste with silent divergences (active-project vs pinned-project time-sig read) | Med | **Fix-now, folded into Q-W3** — extract shared `stampCaptureSample` helper; divergent bits stay in the realtime caller | Q-W3 opens both backend TUs anyway; keeps one review of precision-invariant-adjacent code | +| T2-10 | Thumbnail cache-invalidation drifted across the split (extension: pure generation-baked key; editor: ad-hoc string key + call-site `clear()`s) | Low | Document-and-defer — adopt the pure `ThumbnailKey` on the VST side **as a rider on Q-W2v's editor work** | No live bug; pointless as standalone churn, natural rider on the editor wave | +| T2-11 | ComponentState v1→v11 deserialize chain sound, but v3/v4/v5 legacy branches triplicate the shared read | Low | Document-and-defer, explicitly — record that the next envelope bump (v12) extends the shared path rather than minting another branch | Legacy branches are frozen back-compat contract; rewriting them risks the one thing they must never break | + +### 2.3 Track 3 — env-coupled constants (appendix: `q-w0-t3-env-constants.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T3-01 | Master-gain ramp step is a per-sample constant baking in 20 ms × 48 kHz (`kGainRampRate = 1/960`); FB1 no-zipper contract degrades silently at higher rates | Med | **Fix-now in Q-W0** — store `kGainRampSeconds`, derive step from `sampleRate_` (the file's own `kPreserveWindowMs` pattern) — **§4d** | Trivial, isolated, behavior-identical at 48 kHz; a live violation of the no-hardcoded-rate ruling in a file no then-planned wave opens | +| T3-02 | Takeover-declick decay is a per-frame coefficient (~2× faster at 96 kHz) — documented deliberate in-code | Low | Document-and-defer — triage ratifies the in-code note as the record | Already an explicit, written, bounded design decision; converting buys no audible improvement | +| T3-03 | Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames (knob full-scale varies per source rate) | Low | **Fix-now in Q-W0** — `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate; T3's stated fallback (defer, amend comment) if zero UI-feel change is preferred — **§4d** | Small and contained; storage domain unchanged; the editor already threads `frameCount + rate` through pack/unpack | +| T3-04 | Drop-hint banner duration stored in sync-timer ticks (6 × 500 ms) | Low | Document-and-defer; fold in opportunistically if the file is opened | Cosmetic, self-documenting, cadence and decay live three lines apart | +| T3-05 | Systemic: no DPI/content-scale support in either UI surface (all layout constants are physical px at ~96 DPI) | Med | Document-and-defer — record as a named future phase; Q-W1's geometry relocation keeps constants centralized so the eventual scale factor lands in one place | A proper UI-scaling pass is a feature wave of its own, far outside Q-W0's remediation budget | +| T3-06 | Legacy v3 zone-payload lift divides by the *current* project rate (skewed times if the rate changed since write; write-era rate never recorded) | Low | Document-and-defer — this report is the record; the skew is a known, not a future mystery bug | Unrecoverable in principle; the documented residue of the incident that motivated the seconds invariant | +| T3-07 | SOLA correlation-segment cap of 512 frames — frame-domain by design (CPU bound); wall-clock span halves at 96 kHz | Low | Document-and-defer — **resolved against Track 1's verdict, see §2.5(2)** | T3 judged the frame domain arguably correct for a compute bound and handed the quality call to T1 | + +### 2.4 Track 4 — sizing + placement (appendix: `q-w0-t4-sizing.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T4-01 | `bank_panel.cpp` (3459): the plan's six seams no longer land sub-600 — `panel_render` ~700, `panel_input` ~800 | High | **Fix-now → reshapes Q-W2:** eight TUs, adding `panel_layout` and `panel_drag` | Without the two new seams, two of six TUs ship >600 on day one | +| T4-02 | `main.cpp` (1897): `capture_orchestrator` as specced lands ~885 | High | **Fix-now → reshapes Q-W3:** fourth hoist `capture_batch` (batch family + recapture + selection guards) | Recapture is planner-driven like batch and shares the guard machinery — it belongs with batch | +| T4-03 | `actions.cpp` (1016): plan's seams still land sub-600 | — | No change to Q-W4 (confirmation) | Measured against the current tree | +| T4-04 | `persist.cpp` (852): plan's seams still land sub-600; pS-usage growth landed exactly where the plan isolates it | — | No change to Q-W5 (confirmation) | Measured against the current tree | +| T4-05 | `bank_book.cpp` (1109): `SlotMap` is a self-contained type; post-JSON-extraction remainder splits cleanly | Med | Fix-now, fold into Q-W1 (`slot_map` extraction rides the JSON rewire already opening this file) | One `git mv`-shaped extraction on top of owned work | +| T4-06 | `view_mode_model.cpp` (1049) + `.h` (748): planners separable from model+indexes after JSON extraction | Med | Fix-now, fold into Q-W1 (planner split rides the JSON rewire); T4 allows deferring the planner split if the wave wants to stay minimal (~660 post-extraction is marginal) | JSON rewire opens the file; header splits the same way | +| T4-07 | `bank_model.cpp` (767): model vs JSON — the Q-W1 poster child, ~250 after extraction | — | Already owned by Q-W1; no new seam (confirmation) | — | +| T4-08 | `capture_realtime.cpp` (867): async lifecycle vs file-side finalize are distinct concerns | Low-Med | Fix-now, ride Q-W3 (`capture_realtime_finalize.cpp` split rides the Q-9 naming rider) | Same-wave file surgery is free | +| T4-09 | `view.cpp` (677): park/restore vs D2 lane machinery are separable halves | Low | Document-and-defer unless Q-W1's relocation touches it — then take the free `view_lanes` split | 677 is barely over the bar | +| T4-10 | `ingest.cpp` (636): pure WAV/PCM build helpers trapped in a shell TU | Low-Med | Fix in whichever wave lands the WAV consolidation — **moment is the §4e triage question** (circular with T4-23; no current wave opens `ingest.cpp`) | Extracting the pure WAV build gains a test target and drops the shell to ~500 | +| T4-11 | `vst/reasampler_editor.cpp` (3065): the largest unowned file — the `bank_panel` of the VST artifact | Highest | **Fix-now → new wave Q-W2v:** eight TUs (session / controls / layout / paint ×2 / input ×2 / platform), split axis = the face structure | Grew past `main.cpp` after the plan was written; same god-module profile | +| T4-12 | `vst/reasampler_processor.cpp` (1164): four seams | Med-High | **Fix-now → Q-W2v:** three TUs (`processor_state` / `processor_reload` / lifecycle+`process()` whole) | Partial-class-across-TUs is the same pattern as the Q-W2 panel split | +| T4-13 | `vst/sample_map.cpp` (970) + `.h` (708): resolution core vs binary ComponentState codec | Med-High | **Fix-now → Q-W2v:** split `component_state_io.cpp` + matching header split — one disposition with T2-07, see §2.5(3) | The codec grows every envelope bump (v6→v11 in one quarter); reduces editor/processor rebuild fan-out | +| T4-14 | `vst/sampler_core.cpp` (968) + `.h` (762): TU is a genuine single responsibility on the hottest path | Med | **Fix-now → Q-W2v, header only:** split `zone_params.h` from `sampler_core.h`; **TU stays whole — documented exception to the 600 bar** (record in the wave brief so nobody "fixes" it later) | Same-TU inlining on the per-sample path; no LTO in the build (see T4-27) | +| T4-15 | `view_mode_model.h` (748) | — | Covered under T4-06 (splits with its TU) | — | +| T4-16 | `vst/sample_map.h` (708) | — | Covered under T4-13 | — | +| T4-17 | `vst/sampler_core.h` (762) | — | Covered under T4-14 | — | +| T4-18 | VST placement, leading recommendation: integrate into the one `core/`/`shell/` split with `instrument/` subsystem dirs | — | **Daniel's fork — §4a** (T4 recommends T4-18) | One rule, no special case; the artifact boundary is a link-graph fact the sources already straddle | +| T4-19 | VST placement, alternative: parallel artifact-first subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) | — | **Daniel's fork — §4a** | Defensible if artifact legibility outweighs discipline uniformity; T4 notes its self-containment is cosmetic | +| T4-20 | Little-endian byte codec hand-rolled ×5 — real template win (`putLE`/`readLE`) | Med | Fix-now, lands with `component_state_io` in Q-W2v (its biggest consumer); other consumers rewire opportunistically — relationship to T2-03/T2-04 noted in §2.5(5) | Compile-time dispatch, zero runtime cost, entirely off hot paths; gives the codec a tested primitive | +| T4-21 | Rect family: unify with one **concrete** `ui::Rect` + `contains()` + per-role aliases — NOT a template; retire the XYWH-vs-LTRB fork | Med | **Fix-now, ride Q-W1** — reconciled with T2-05 into one disposition, see §2.5(1) | The types differ in name only; a template would model nothing; cross-lib `Rect` collision is fixed by the Q-4 sub-namespaces | +| T4-22 | Linear rect-scan hit-tests: one small `hitIndex` template collapses them — only after T4-21 | Low | Document-and-defer; opportunistic rider on Q-W1 once the rect unification lands | Alone it would be a forced template | +| T4-23 | WAV build/parse consolidation into one pure `wav_codec` owner (dedup, not template) | Low-Med | Fix-now-sized, but the owning moment is the **§4e triage question** (T4 assigns it to the wave opening `ingest.cpp`; T2-08 prefers the `core/wav` homing moment; no current wave opens ingest) | Avoid a standalone churn commit; one tested owner of the RIFF layout | +| T4-24 | `clamp01` ×6 — one `constexpr inline` (or `std::clamp` at sites); anti-template | Trivial | Fix-now, rider on Q-W1 relocation | — | +| T4-25 | JSON `Parser` ×4 confirmed still accurate; `sample_usage` is **not** a fifth JSON parser (no scope growth from this track) | — | No change to Q-W1 (confirmation; T2-02's fifth decoder is a different file and does grow the consumer list) | — | +| T4-26 | `ICaptureBackend` is a dead abstraction: one deriver, zero polymorphic call sites; the brief's "two implementations" assumption is FALSE (realtime deliberately has a bespoke seam) | Low (runtime) / Med (hygiene) | **Fix-now, in Q-W3:** delete the interface, `OfflineRenderBackend` becomes concrete; **correct CLAUDE.md/CONTEXT ("ICaptureBackend interface; two backends") in the same commit** | It misleads — this audit's own brief was misled; Q-W3 touches every call site | +| T4-27 | Warning: do **not** split `sampler_core.cpp` along class lines — envelope `tick()`s are per-voice-per-sample; a by-class split is the exact heuristic-(c) dispatch blowout | — | Record as a guardrail in the Q-W2v brief (pairs with T4-14's whole-TU exception) | Same-TU definition is what lets the compiler inline the stack today | +| T4-28 | Warning to Q-W2: audition/preview stays a direct call-through across the 8-TU split; per-mouse-move work stays plain free-function calls | — | Record as a guardrail in the (reshaped) Q-W2 brief — reaffirms the plan's own guardrail; the two added TUs introduce no new risk | — | +| T4-29 | Warning to the processor split: `process()` + per-block helpers stay one TU; the atomic-pointer-swap pattern must not gain a virtual seam | — | Record as a guardrail in the Q-W2v brief | — | +| T4-30 | No other gratuitous indirection found (verified: only extension-side `virtual` is T4-26; VST virtuals are SDK-mandated; layered pure→shell pairs are the discipline, all direct calls) | — | Clean verification — see §5 | — | + +### 2.5 Cross-track dedupes and reconciliations + +1. **T2-05 ≡ T4-21 (+ T4-22 rider) — the rect zoo. Reconciled into ONE disposition: fix-now, + ride Q-W1.** Both tracks found the same duplication (19 XYWH structs + inline predicates; + T4-21 adds the VST side's second LTRB grammar) and both prescribe the same mechanism — one + concrete `ui::Rect` + `contains()` with per-role aliases, explicitly **not** a template + (T4-21's ruling). The only divergence was the wave label: T2-05 said "Q-W2 (the ui/ + relocation wave)", but in the plan the relocation wave — and the settled `ui::` rect + unification (PLAN §Q-W1, "shared pure-UI rect types … get one `ui::` owner") — is **Q-W1**. + T2-05's own rationale ("same-moment-as-relocation") therefore points at Q-W1; reconciled + there. T4-22's hit-test template stays a deferred opportunistic rider behind it. +2. **T3-07 → T1 — the 512-frame correlation cap. Resolved: document-and-defer.** T3 handed the + "is 512 the right number" quality call to Track 1. Track 1's verdict on the correlation + search's bounds (T1-06) is document-and-defer — record the alignment limits as the engine's + stated operating range; "widening either costs splice-burst CPU linearly." Note for accuracy: + T1-06 adjudicates the coarse-step and `maxLag` bounds specifically and does not name the + 512-frame `corrFrames_` cap; the resolution here rests on T1's general verdict (bounded + limits recorded, no change recommended) applied to the same search-cost family. If triage + wants the 512 value separately adjudicated, that is a residual question — flagged rather than + silently absorbed. +3. **T2-07 ≡ T4-13 — the ComponentState codec split. One disposition: Q-W2v's + `component_state_io`.** T2-07's complaint (extension links the whole voice engine to share + the preset serializer) is *solved by* T4-13's proposed split; T2-07's link-weight rationale + rides the T4-13 seam. T4-14's `zone_params.h` header split completes the extension-side + decoupling. T2 had provisionally pointed at "Q-W1/Q-W2 module-homing"; with Q-W2v now + proposed as the wave that opens `sample_map`, that is the owning wave. +4. **T2-06 ≡ T4-11's `editor_layout` — the stranded editor layout math.** T2-06's + document-and-defer explicitly demanded a *named* downstream reshape; Q-W2v's `editor_layout` + TU (T4-11, marked "pure candidate") is that reshape. One disposition: assigned to Q-W2v, with + the hoist targeting the existing pure homes (`editor_geometry` is T2's named natural owner). + T2-06's scope note (the §2 god-module catalogue missed the VST side) is also the evidence + base for Q-W2v existing at all. +5. **T4-20 / T2-03 / T2-04 — related but distinct dedup family, three separate dispositions.** + All three converge on shared `core/` utility homes but touch disjoint code: T4-20 (LE byte + codec ×5) lands with `component_state_io` in Q-W2v; T2-03 (`readFileBytes` ×5) lands in + Q-W1's utility home; T2-04 (ext-state grow-loop ×3, generalizing `bridge_marshal`) lands in + Q-W5 with the persist split. Marked here so triage sees them as one family and no wave + assumes another already covered its slice. +6. **T2-08 / T4-23 / T4-10 — WAV/RIFF consolidation. Genuine track disagreement on the moment; + surfaced as §4e** rather than silently picked. T2-08 defers to "the reorg wave that + relocates `wav_trim`" (Q-W1's relocation); T4-23 assigns to "the wave that opens + `ingest.cpp`" — and T4-10 assigns the ingest split to "whichever wave lands the WAV + consolidation," which is circular: **no current wave opens `ingest.cpp`.** +7. **T1-11 — capture-tag collision.** T1 offered it to Track 2 ("Track 2 may claim it"); + Track 2 did not. It remains a single T1 finding, routed per T1's own suggestion to Q-W3. +8. **Cross-track interaction on the DSP/VST fix-nows.** T1 and T3 both justified + fix-now-in-Q-W0 partly by "no downstream wave opens these files" — written before T4 + proposed Q-W2v, which *does* open `reasampler_processor.cpp` / `reasampler_editor.cpp` / + `sampler_core.h`. The recommendation stands unchanged: Q-W2v is a behavior-preserving + mechanical-split wave, and folding behavior-changing DSP/domain fixes into it would break the + wave discipline (CTest-green mechanical moves, no logic change). Fix-now items stay in Q-W0; + noted so the rationale reads correctly against the reshaped plan. + +--- + +## 3. Plan reshape — what Q-W0 proposes for Q-W1..Q-W6 + +The concrete deltas to the PLAN §Phase Q wave graph. Every wave is named; "no change" +confirmations included deliberately. + +**Q-W0 (this wave, post sign-off) — remediations before close:** T2-01(a) provenance cursor +hardening backport; T3-01 gain-ramp seconds; T3-03 fade-ceiling seconds (or its stated fallback); +T1-07 comment fix; pending §4b — T1-01 linked-lag stereo fix and T1-03 prime bound, with T1-09 +riding any `sampler_core` edit. Each behavior-touching remediation lands with its module's CTest +target green per the Q-W0 verify contract. + +**Q-W1 (safe opener — scope grows):** +- Add `tail_control` to the `core/json` consumer list (T2-02) — the wave's "one JSON path" goal + is not met without it. (T4-25 confirms the original 4× scope is otherwise accurate.) +- Add the shared `readFileBytes` pure helper to the `core/` utility home (T2-03). +- Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases, including + retiring the XYWH-vs-LTRB fork and folding in `editor_geometry`'s `Rect` (T2-05 ≡ T4-21); + `clamp01` dedup rider (T4-24); `hitIndex` template only as an opportunistic follow-on (T4-22). +- Structural collapse of the wire `Cursor` family into one shared `wire` codec module beside + `core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` / + `parseBankGeneration` (T2-01(b)). +- `slot_map` extraction rider on the `bank_book` JSON rewire (T4-05); `view_mode_model` planner + split rider (T4-06, optional per T4); `bank_model` unchanged-as-planned (T4-07); `view_lanes` + split only if relocation touches `view.cpp` anyway (T4-09). +- Relocation scope grows to include the ~20 clean VST pure libs, under whichever placement shape + §4a settles (T4 §1.5, T4-18/T4-19). + +**Q-W2 (bank_panel split — 6→8 seams):** the wave brief must name **eight** TUs — the planned +six plus `panel_layout` and `panel_drag` (T4-01) — or two of its TUs ship >600 on day one. +Guardrails reaffirmed: audition direct call-through; per-mouse-move work stays plain free-function +calls (T4-28). + +**Q-W2v (NEW — VST god-module wave; the T2-06/T4 §1.5 scope gap made structural):** +- `reasampler_editor.cpp` → eight TUs: session / controls / **layout (pure-candidate hoist into + the existing pure homes — this discharges T2-06)** / paint_sample / paint_browse_zone / + input_sample / input_browse_zone / platform (T4-11). +- `reasampler_processor.cpp` → three TUs: `processor_state` / `processor_reload` / + lifecycle+`process()` kept whole (T4-12), with the T4-29 guardrail (no virtual seam on the + atomic-swap pattern). +- `sample_map` → `component_state_io` codec split + matching header split (T4-13 ≡ T2-07). +- `sampler_core`: **TU stays whole at 968 — documented hot-path exception** recorded in the wave + brief (T4-14, T4-27); header splits into `zone_params.h` + `sampler_core.h`. +- The `core/wire/bytes.h` LE-codec template lands here with its biggest consumer (T4-20). +- Rider: adopt the pure `ThumbnailKey` on the VST side while the editor is open (T2-10). +- **Scheduling:** parallel-safe with Q-W2 (different artifact, zero file overlap) — T4 also + offers serial-after-W5 as "Q-W7" if Daniel prefers serial waves (§4f). + +**Q-W3 (main.cpp split — 3→4 hoists):** add **`capture_batch`** (batch family + +`RunRecaptureFromSource` + the two selection guards) as a fourth TU so `capture_orchestrator` +lands ~450 (T4-02). Also owned here: **delete `ICaptureBackend`** and correct the +CLAUDE.md/CONTEXT description in the same commit (T4-26); the shared `stampCaptureSample` +epilogue dedup (T2-09); the `capture_realtime_finalize` split riding the Q-9 naming rider +(T4-08); the `makeUniqueTag` monotonic-counter fix (T1-11). + +**Q-W4 (actions split): no change** — the planned seams still land sub-600 (T4-03). + +**Q-W5 (persist split): seams unchanged** (T4-04); **add** the ext-state grow-loop dedup — +generalize the retry policy into `bridge_marshal` (or its `core/` successor) and rewire all three +loops, `usage_scan`'s prune-safety-adjacent copy included (T2-04). + +**Q-W6 (registration table): no change**; T4-02 notes the `main.cpp` registration residue (~385) +shrinks further under its table. + +**Unassigned pending §4e:** the WAV/RIFF consolidation family (T2-08 / T4-23 / T4-10) — no +current wave opens `ingest.cpp`; the owning moment is Daniel's call. + +Updated dependency sketch: + +``` +Q-W0 (this report + remediations) ── SUB-GATE: Daniel signs off every disposition (§4) ── + ▼ +Q-W1 (core/json + wire codec + relocation incl. VST pure libs + rect unification + riders) + ├─► Q-W2 (bank_panel split, 8 seams) ──► Q-W4 (actions; unchanged) + ├─► Q-W2v (NEW: VST god-modules — editor/processor/sample_map splits, sampler_core header, + │ LE codec) [parallel-safe with Q-W2; serial "Q-W7" alternative — §4f] + ├─► Q-W3 (main split, 4 hoists; ICaptureBackend deletion + doc fix; stamp dedup; T1-11) + │ └──► Q-W6 (registration table; unchanged) + └─► Q-W5 (persist; seams unchanged; + ext-state-loop dedup) [best after Q-W4] +``` + +--- + +## 4. Daniel's decision list (sign-off gate) + +Each item: leading recommendation first, alternative second. Settled matters (Q-10, Q-11 framing, +the clean bills) are deliberately absent. + +- **(a) VST placement fork — T4-18 vs T4-19.** *Recommend T4-18:* integrate `src/vst/` into the + one `core/`/`shell/` top split with `instrument/{engine,map,ui}` subsystem dirs — one rule + ("directory = may it touch a host type"), the artifact boundary is a link-graph fact the + sources already straddle, CMake impact is path-edits only. *Alternative T4-19:* artifact-first + subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) — cleanest expression + of the Windows-only gate, at the cost of two parallel `core/` trees and a subtree whose + self-containment is cosmetic. Q-W1 executes whichever shape is chosen. +- **(b) DSP bounded fixes in Q-W0 — T1-01 and T1-03.** *Recommend fix-now for T1-01* (linked + L/R lag + splice schedule): High severity on the flagship stereo-Preserve path, standard + stereo-SOLA practice, no technique change. *Alternative:* defer and record as the known + stereo-Preserve limitation. *For T1-03* (prime bound + immediate `freezeTail()` on + sub-window spans): T1's own framing — fix-now **if the short-one-shot case matters** to you + (short drum one-shots are realistic content); else document-and-defer with the T1 note as the + record. T1-09 (`declickR_` dead state) rides whichever `sampler_core` edit happens. +- **(c) T2-01(a) provenance wire-cursor hardening backport in Q-W0.** *Recommend yes:* small, + pure, closes a concrete robustness gap on persisted user-editable input, covered by existing + `provenance_tests`. *Alternative:* wait for the Q-W1 wire-codec collapse to fix it + structurally — leaves the gap open through the gate for no saving. +- **(d) Env-constant fix-nows in Q-W0 — T3-01 and T3-03.** *Recommend fix-now for both:* each + is trivial, isolated, and a live hardcoded-rate residue Daniel's standing ruling forbids; + behavior-identical at the baked-in rates. *Alternative for T3-03 only* (T3's stated + fallback): document-and-defer with the comment amended to name the 44.1 k assumption, if zero + UI-feel change is preferred. (Folding either into Q-W2v instead is *not* recommended — it + would put behavior changes inside a mechanical-split wave; §2.5(8).) +- **(e) WAV/RIFF consolidation moment — the one true track disagreement (T2-08 vs T4-23/T4-10).** + T2 prefers the `core/wav` homing moment (the relocation wave); T4 prefers "the wave that opens + `ingest.cpp`" — which does not exist, and T4-10 points back circularly. *Recommend:* record + the consolidation (one pure `wav_codec` owner: walker + layout + build + patch, absorbing + T4-10's ingest extraction) as a named rider on **Q-W3** — the wave already opening the capture + family (`capture_realtime` finalize, T4-08) — with Q-W1-relocation as the alternative moment + if Daniel prefers T2's framing. Either way it must land somewhere named, or the + dedup-by-hash/null-test maintenance surface stays quadruplicated. +- **(f) Q-W2v scheduling.** *Recommend parallel with Q-W2* (different artifact, zero file + overlap — T4 §1.5). *Alternative:* sequence it serially after W5 as "Q-W7" if you want serial + waves throughout. + +--- + +## 5. Clean bills — surfaces audited and found clean + +Consolidated coverage evidence; details in the appendices' clean-surface sections. + +**DSP (T1):** `peaks` (bin partition exact, overflow-guarded, per-channel no-fold), `master_gain` +(taper math correct end to end), `velocity_curve` (Fritsch–Carlson monotonicity/no-overshoot +claims hold) — fully clean. Clean with only the noted findings: `wav_trim` (T1-10 metadata note), +offline capture path (T1-11; precision-invariant plumbing "disciplined"), realtime capture path +(T1-11 + already-in-code DAW-verify flags), `sampler_core`'s voice/steal/mono/panic machinery +(rate-free-seconds invariant honored; takeover blend mathematically sound), `pitch_shift`'s core +machinery (safe-band geometry, clamps, correlation, fades, `freezeTail` continuity all audit +sound; down-shift writer-lap unreachable above ≈ −109 st). + +**Architecture (T2):** pure-module include hygiene across both trees (zero host-type includes in +any claimed-pure module); `bank_sync`; `bridge_marshal`; the realtime record lifecycle (explicit +enum state machine, not implicit); project-identity transitions; `usage_scan` (decisions +delegated pure, depth-bounded recursion, protect-on-truncation); the three `catch (...)` sites +(documented, narrow, non-swallowing); `FxBypassGuard` vs `view.cpp` park/restore (duplication of +shape, not concept — correctly separate); path resolution (`resolveBankFile` is the single +resolver both sides); WAV decode (one decoder); the draw layer (no parallel vocabulary on the VST +side); interface cost (no hot-path virtual/`std::function` chains); boolean parameters (no +smell). Also T2-11's headline: the ComponentState v1→v11 lift chain is **functionally sound** — +the finding is shape, not correctness. + +**Env-coupled constants (T3):** `sample_map` v5+ persistence (the reference implementation); +ComponentState v6–v11 fields; Trigger fade/loop frames as source-file facts with the rate stored +alongside; `trigger_seam`; `kPreserveWindowMs` (the model pattern); `pitch_shift` internal +geometry; the tail system; `wav_trim`; `bank_model` persisted metadata; all ext-state wires; the +envelope schematic's param-domain scaling; every timer surveyed; `peaks` / `waveform_view` / +`master_gain` / `velocity_curve` / `keyboard_strip`. The persistence layer — the category's +highest-stakes surface — is clean end to end. + +**Sizing/indirection (T4):** borderline files needing no action: `capture.cpp` (549), +`reasampler_editor.h`/`reasampler_processor.h` (shrink with their TU splits), `bank_book.h`, +`pitch_shift.cpp` (371 — single responsibility, hot, leave), `persist.h`/`capture.h` (owned by +Q-W6). T4-30: no gratuitous indirection anywhere beyond the dead `ICaptureBackend` — VST-side +virtuals are SDK-mandated, and the layered pure→shell pairs are the load-bearing discipline, all +direct calls. Plan-confirmations: Q-W4 and Q-W5 seams land as planned (T4-03/T4-04); Q-W1's JSON +scope confirmed accurate modulo T2-02 (T4-25). From b06b2261825d6442787521848a7c04c5d2e9389f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 17:58:19 -0400 Subject: [PATCH 06/40] =?UTF-8?q?docs:=20queue=20TODO-1.0=20=E2=80=94=20po?= =?UTF-8?q?st-1.0=20specs=20for=20envelope=20curve=20dials/overlay=20radio?= =?UTF-8?q?,=20MM=20preamp=20filter=20stage,=20and=20hard-point=20Spline?= =?UTF-8?q?=20EGs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 TODO-1.0.md diff --git a/TODO-1.0.md b/TODO-1.0.md new file mode 100644 index 0000000..3de84d9 --- /dev/null +++ b/TODO-1.0.md @@ -0,0 +1,252 @@ +# TODO-1.0 + +Post-1.0 enhancement queue for the ReaSampler 9000 instrument. Three items, in +Daniel's ordering (2026-07-28). Deliberately specified at the level of product +intent, user-visible behavior, and acceptance criteria — **no implementation +design, no file/module references**. These were authored while Phase Q was +restructuring the tree; the implementing engineer maps each spec onto the +post-Q layout at execution time. Each item preserves Daniel's raw ask verbatim +as the source of truth, then breaks it into behavior, open questions, and an +observable acceptance gate. + +Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's +filter envelope ("filter to be added"), and item 3 layers on both. They can land +in sequence or together, but 1 and 2 are prerequisites for 3's full surface. + +--- + +## 1 — Envelope editor: per-deck radio switch, segment curve dials, overlay recolor + +**Daniel's ask (verbatim, 2026-07-28).** + +> The VST envelope editor visual is a great start but we need to add some +> features. The Amp AHDSR is currently always displayed. We need to add a radio +> switch to the corner of each env knob deck which makes THAT envelope editable +> via the graphic waveform overlay. This is currently the amp and pitch +> envelopes, with filter to be added (see below). All envelopes (amp AHDSR, +> Pitch AD, Filter AHDSR) should be enhanced to have editable segment curve +> values. This should be an exponential function with the exponent scalar +> parameter for each sloped segment having possible values from 0.1 to 10. The +> knob deck radial knobs for the sloped curvable segments will have an INNER +> dial, with its own inner arc, hover accent (tertiary purple), needle, and +> numerical label which controls the curve for that sloped segment. Also the +> graphical envelope editor draws its segments in secondary blue, which +> contrasts poorly against the primary green waveform display. Change those to +> tertiary purple. + +**Intent.** Grow the envelope-overlay editor from an amp-only fixture into the +shared graphical surface for every envelope in the instrument, and give every +envelope shapeable (non-linear) segments — while fixing the blue-on-green +contrast failure. + +**Behavior.** + +- **Radio switch per envelope deck.** Each envelope knob-deck group (currently + AMP ENVELOPE and PITCH ENV; the filter envelope joins when item 2 lands) + gains a radio switch in the corner of its deck. Selecting a deck's radio + makes *that* envelope the one displayed and editable in the graphic waveform + overlay — replacing today's behavior where the Amp AHDSR is always the + displayed envelope. +- **Segment curve values on all envelopes.** Amp AHDSR, Pitch AD, and Filter + AHDSR all gain an editable curve value per *sloped* segment. The curve is an + exponential function; the per-segment parameter is the exponent scalar, + range **0.1 to 10**. +- **Inner dial on curvable-segment knobs.** Every knob-deck radial knob that + controls a sloped, curvable segment gains an **inner dial**: its own inner + arc, its own hover accent (tertiary purple), its own needle, and its own + numerical label. The inner dial controls the curve exponent for that + segment; the outer knob keeps controlling the segment's time/level value as + today. +- **Overlay recolor.** The graphical envelope editor's segments change from + secondary blue to **tertiary purple** (secondary blue contrasts poorly + against the primary green waveform behind it). + +**Open questions.** + +- **Radio exclusivity + default.** "Radio" implies exactly one envelope is + overlay-active at a time across all decks — confirm that reading, and + confirm the Amp AHDSR remains the default selection on open. +- **Which segments are "sloped."** Per envelope type, which segments carry a + curve dial? (E.g. Attack/Decay/Release obviously slope; Hold and Sustain + presumably do not — needs Daniel's confirmation per stage.) +- **Curve default and neutral point.** Is exponent 1.0 the linear/neutral + default, and should existing saved instances load with all curves at the + linear-equivalent so their sound is unchanged? (Persistence extension is + implied; the product requirement is only "old instances sound identical.") +- **Curve editing in the overlay.** Is the curve exponent editable *from the + overlay itself* (e.g. dragging a segment's belly), or is the inner dial the + sole curve-edit affordance and the overlay edits node positions only? + Daniel's ask specifies the dial; overlay curve-drag is not stated either way. + +**Acceptance criteria.** + +- Each envelope deck shows a corner radio switch; activating one puts that + envelope in the overlay, editable there, and the overlay tracks the switch + immediately. +- Every curvable-segment knob shows the inner dial (inner arc, tertiary-purple + hover accent, needle, numeric label); sweeping it through 0.1 → 10 visibly + reshapes the overlay segment and audibly reshapes the envelope on played + notes. +- Overlay envelope segments render in tertiary purple and are clearly legible + against the primary green waveform. +- A project saved before this change reopens with unchanged audible envelope + behavior. + +--- + +## 2 — MM preamp Filter: resonant HP/LP stage in the voice pipeline + +**Daniel's ask (verbatim, 2026-07-28).** + +> MM preamp Filter: We must implement a new processing point in the sampler +> audio pipeline, after pitch env, before amp, for filtering. The processor for +> this will be based on code I wrote for the cortex M4 for resonant high and +> lowpass filtering. The filter will have parameters for mode, cutoff, Q, and +> mod amt, then the AHDSR controls as described above. The knob deck row will +> then be relaid out in signal flow order: pitch -> filter -> amp + +**Intent.** Add the instrument's first filter stage — resonant high-pass and +low-pass — as a new fixed point in the per-voice signal path, with its own +AHDSR envelope, and make the knob-deck row read in signal-flow order. + +**Behavior.** + +- **Pipeline position.** A new processing point in the sampler audio pipeline: + **after the pitch envelope, before the amp stage.** +- **DSP source.** The filter processor is based on Daniel's own Cortex-M4 + resonant high/lowpass filter code. **That code is an input Daniel supplies at + implementation time** — it is not in this repo and this spec does not + characterize it beyond "resonant high and lowpass." +- **Parameters.** Mode, cutoff, Q, and mod amt — then the AHDSR controls, + treated exactly as item 1 specifies (curvable sloped segments with inner + dials, overlay editability via the filter deck's radio switch). +- **Deck reorder.** The knob deck row is relaid out in signal-flow order: + **pitch → filter → amp**. + +**Open questions.** + +- **Mode list.** Is mode a discrete selector, and over exactly which entries? + "Resonant high and lowpass" confirms HP and LP; whether band-pass/notch or + multiple slopes exist depends on the Cortex-M4 source and Daniel's intent. +- **Ranges and units.** Cutoff range (Hz), Q range, and mod-amt range/polarity + (unipolar or bipolar?) are unstated — likely settled by the Cortex-M4 code + plus a Daniel call at implementation time. +- **Mod routing.** "Mod amt" presumably scales the filter AHDSR's modulation of + cutoff — confirm the target is cutoff and whether any other mod sources + (velocity, key-tracking) are in scope now or deferred. +- **Scope: per-zone or per-instance.** Existing playback parameters live + per-zone with Sample/Zone panel parity; VOICE and MASTER are per-instance + exceptions. Which side does the filter fall on? +- **Neutral default.** Should the filter default to a bypass/neutral state so + pre-existing saved instances (and freshly loaded captures) sound unchanged + until the user engages it? +- **Display name.** Is "MM Preamp Filter" the user-facing label for the deck + group, or working shorthand? + +**Acceptance criteria.** + +- With the filter engaged, played notes are audibly filtered at the specified + pipeline point: the filter acts on pitched (post-pitch-envelope) signal, and + the amp envelope still shapes the filtered result (audible ordering: + pitch → filter → amp). +- Mode, cutoff, Q, and mod-amt controls appear in a filter deck group; the + filter AHDSR gets the full item-1 treatment (curve inner dials, corner radio + switch, tertiary-purple overlay editing). +- High Q audibly emphasizes the cutoff region (resonance) in both HP and LP + modes. +- The deck row reads pitch → filter → amp left-to-right. +- A project saved before this change reopens sounding identical (pending the + neutral-default confirmation above). + +--- + +## 3 — Alternative Spline EGs: hard/smooth multi-segment monotonic splines + +**Daniel's ask (verbatim, 2026-07-28).** + +> Alternative Spline EGs: Every processor which has an envelope will have the +> ability to change the Staged EG to a Spline EG, based on the monotonic +> splines for the velocity curve. HOWEVER, we will need to enhance the (singly +> implemented, multi referenced) spline algorithm to support multiple segments +> that DON'T minimally smooth the spline, so that hard points are possible. In +> other words, the contour of the EG will be defined by 1 or more monotonic +> spline functions which together form the full time function for that +> processor, such that the first three points could make a curved segment, +> which connects at a sharp angle to the next three points, finishing out the +> contour over the full sample length. Control-clicking a point makes it a +> "hard" or "smooth" point (toggled, smooth by default) which when hard does no +> curve smoothing on either side segment, forming the natural sharp angle +> instead of a continuous derivative. This enhanced spline drawing will be used +> for the velocity-amp transfer curve as well as the pitch, filter, and amp EGs +> (if they are in spline mode). + +**Intent.** Offer a free-drawn alternative to every staged envelope: the user +switches any EG from Staged to Spline mode and draws the contour directly, with +the monotonic-spline machinery already proven by the velocity curve — enhanced +so sharp corners are possible, not everything smoothed. + +**Behavior.** + +- **Mode toggle per EG.** Every processor that has an envelope (pitch, filter, + amp) can switch its Staged EG to a **Spline EG**. +- **Spline foundation.** The Spline EG is based on the monotonic splines used + for the velocity curve. The spline algorithm is singly implemented and + multi-referenced; the enhancement below applies to that one implementation + and flows to every consumer. +- **Hard points.** The algorithm is enhanced to support **multiple segments + that don't minimally smooth the spline**, so hard points are possible: the + EG contour is defined by **one or more monotonic spline functions** which + together form the full time function for that processor — e.g. the first + three points form a curved segment that connects **at a sharp angle** to the + next three points, finishing the contour over the **full sample length**. +- **Hard/smooth gesture.** **Control-clicking a point toggles it hard/smooth + (smooth by default).** A hard point does no curve smoothing on either + adjacent segment — the natural sharp angle stands instead of a continuous + derivative. +- **Shared across consumers.** The enhanced spline drawing serves the + **velocity→amp transfer curve** as well as the pitch, filter, and amp EGs + (when those are in spline mode). The velocity curve gains hard-point support + by the same enhancement. + +**Open questions.** + +- **Staged↔Spline toggle semantics.** What happens to a Staged EG's values + when toggling to Spline mode and back? (Convert the staged shape into an + initial spline? Keep two independent per-mode states? Discard?) Daniel has + not said — this materially shapes both UX and persistence and needs his + call. +- **Gate-mode sustain/release.** A Staged Gate envelope holds at Sustain and + releases on note-off; a spline contour drawn "over the full sample length" + is a pure time function. How do note-off, sustain, and looped playback map + onto a Spline EG in Gate mode? (Trigger/one-shot is the natural fit; Gate + needs a stated rule.) +- **Time axis.** Is the contour's time axis normalized to the sample length + (so it rescales with the sample) or absolute seconds? "Over the full sample + length" suggests sample-relative — confirm, especially for looped sustain. +- **Monotonicity meaning.** The velocity spline is monotone to prevent + overshoot on a transfer curve. An EG contour must rise and fall; presumably + "monotonic" here means the per-segment no-overshoot property (values never + exceed the segment's endpoints), not a globally monotone function — confirm + the intended guarantee. +- **Point-editing grammar.** Point add/delete gestures and any point-count + bound are unstated. The velocity-curve popup already established right-click + node delete — reuse of that grammar seems natural but is Daniel's call. +- **Interaction with item-1 curve dials.** In Spline mode, do the staged + segment knobs/inner dials go inert (shape comes wholly from the spline), or + is some hybrid intended? Presumably the former — confirm. + +**Acceptance criteria.** + +- Each of the pitch, filter, and amp EGs offers a Staged/Spline mode switch; + in Spline mode the overlay (via the item-1 radio switch) shows and edits the + drawn contour, and played notes audibly follow it. +- Control-click toggles any point hard/smooth; points are smooth by default; a + hard point renders a visible sharp angle with no smoothing on either + adjacent segment, and the discontinuous slope is audible where the + modulation target makes it so (e.g. a pitch EG corner). +- A contour of several segments joined at hard points plays back over the full + sample length exactly as drawn. +- The velocity→amp transfer-curve editor supports the same control-click + hard/smooth toggle with identical rendering behavior. +- Staged↔Spline toggling behaves per the answered open question above (that + answer gates this item's completion definition). From a4571cc0745972e535d7a98bbe8de17066b16d31 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 18:44:41 -0400 Subject: [PATCH 07/40] =?UTF-8?q?docs(TODO-1.0):=20fold=20in=20Daniel's=20?= =?UTF-8?q?answers=20=E2=80=94=20radio=20none-state,=20knot=20curve-drag,?= =?UTF-8?q?=20Filter=20ranges/label,=20dual-state=20Spline=20EGs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 176 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 118 insertions(+), 58 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 3de84d9..96e28f8 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -9,6 +9,12 @@ post-Q layout at execution time. Each item preserves Daniel's raw ask verbatim as the source of truth, then breaks it into behavior, open questions, and an observable acceptance gate. +A follow-up answer round from Daniel (2026-07-28) settled most of the open +questions. Each item now carries his follow-up verbatim alongside the original +ask; settled answers are folded into **Behavior** (marked *settled by +follow-up*), and only the genuinely unresolved remainder stays under **Open +questions**. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -34,6 +40,12 @@ in sequence or together, but 1 and 2 are prerequisites for 3's full surface. > contrasts poorly against the primary green waveform display. Change those to > tertiary purple. +**Daniel's follow-up (verbatim, 2026-07-28).** + +> yes, one overlay (or none). Everything besides Hold and Sustain, right? "is +> 1.0 the linear neutral" CLAUDE, y=x^1.0 is linear!! of course! yes dragging +> on the segment (add a round knot midsegment) adjusts the curve. + **Intent.** Grow the envelope-overlay editor from an amp-only fixture into the shared graphical surface for every envelope in the instrument, and give every envelope shapeable (non-linear) segments — while fixing the blue-on-green @@ -46,11 +58,22 @@ contrast failure. gains a radio switch in the corner of its deck. Selecting a deck's radio makes *that* envelope the one displayed and editable in the graphic waveform overlay — replacing today's behavior where the Amp AHDSR is always the - displayed envelope. + displayed envelope. The switch is exclusive: **one overlay-active envelope at + a time, or none** — no-envelope-shown is a valid state, not an error. + *(Settled by follow-up.)* - **Segment curve values on all envelopes.** Amp AHDSR, Pitch AD, and Filter AHDSR all gain an editable curve value per *sloped* segment. The curve is an exponential function; the per-segment parameter is the exponent scalar, range **0.1 to 10**. +- **Which segments are sloped.** **Every stage except Hold and Sustain** — for + an AHDSR that is Attack, Decay, and Release; for the Pitch AD, both stages. + *(Settled by follow-up.)* +- **Linear neutral.** Exponent **1.0 is the linear neutral** (y = x^1.0 is + linear). *(Settled by follow-up — emphatically.)* +- **Curve editing in the overlay.** Dragging on a segment in the overlay + **adds a round mid-segment knot** whose drag adjusts that segment's curve — + the overlay is a curve-edit surface in its own right, alongside (not instead + of) the inner dial. *(Settled by follow-up.)* - **Inner dial on curvable-segment knobs.** Every knob-deck radial knob that controls a sloped, curvable segment gains an **inner dial**: its own inner arc, its own hover accent (tertiary purple), its own needle, and its own @@ -63,30 +86,28 @@ contrast failure. **Open questions.** -- **Radio exclusivity + default.** "Radio" implies exactly one envelope is - overlay-active at a time across all decks — confirm that reading, and - confirm the Amp AHDSR remains the default selection on open. -- **Which segments are "sloped."** Per envelope type, which segments carry a - curve dial? (E.g. Attack/Decay/Release obviously slope; Hold and Sustain - presumably do not — needs Daniel's confirmation per stage.) -- **Curve default and neutral point.** Is exponent 1.0 the linear/neutral - default, and should existing saved instances load with all curves at the - linear-equivalent so their sound is unchanged? (Persistence extension is - implied; the product requirement is only "old instances sound identical.") -- **Curve editing in the overlay.** Is the curve exponent editable *from the - overlay itself* (e.g. dragging a segment's belly), or is the inner dial the - sole curve-edit affordance and the overlay edits node positions only? - Daniel's ask specifies the dial; overlay curve-drag is not stated either way. +- **Default overlay selection on open.** Exclusivity and the none-state are + settled; what is the default when the editor opens — the Amp AHDSR (today's + behavior) or none? +- **Pre-existing instances.** Should saved instances from before this change + load with every curve at the linear equivalent (exponent 1.0) so their sound + is unchanged? (The neutral point itself is settled; this is only the + load-behavior half. The product requirement is "old instances sound + identical.") **Acceptance criteria.** - Each envelope deck shows a corner radio switch; activating one puts that envelope in the overlay, editable there, and the overlay tracks the switch - immediately. + immediately. At most one envelope is overlay-active; with none active, the + overlay draws no envelope. - Every curvable-segment knob shows the inner dial (inner arc, tertiary-purple hover accent, needle, numeric label); sweeping it through 0.1 → 10 visibly reshapes the overlay segment and audibly reshapes the envelope on played - notes. + notes. Hold and Sustain knobs carry no inner dial. +- Dragging on an overlay segment adds a round mid-segment knot; dragging the + knot adjusts that segment's curve, and the segment's inner dial reflects the + same value. - Overlay envelope segments render in tertiary purple and are clearly legible against the primary green waveform. - A project saved before this change reopens with unchanged audible envelope @@ -105,6 +126,13 @@ contrast failure. > mod amt, then the AHDSR controls as described above. The knob deck row will > then be relaid out in signal flow order: pitch -> filter -> amp +**Daniel's follow-up (verbatim, 2026-07-28).** + +> modes beyond the pass filters will be added later. cutoff range full audio +> spectrum, log scaled, fully open to fully closed, Q should go from 0.1 to 10 +> again, scaled around root 2 at the center. mod amt targets cutoff, -100% - +> +100% to cover full range from either end. per-voice. label it "Filter" + **Intent.** Add the instrument's first filter stage — resonant high-pass and low-pass — as a new fixed point in the per-voice signal path, with its own AHDSR envelope, and make the knob-deck row read in signal-flow order. @@ -120,28 +148,35 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. - **Parameters.** Mode, cutoff, Q, and mod amt — then the AHDSR controls, treated exactly as item 1 specifies (curvable sloped segments with inner dials, overlay editability via the filter deck's radio switch). + *(Ranges settled by follow-up:)* + - **Mode:** high-pass and low-pass now; **modes beyond the pass filters are + explicitly deferred to later.** + - **Cutoff:** the **full audio spectrum, log scaled**, from fully open to + fully closed. + - **Q:** **0.1 to 10** (the curve-exponent range again), scaled so **√2 sits + at the center** of the control. + - **Mod amt:** **bipolar, −100% to +100%**, targeting **cutoff** — covering + the full range from either end. +- **Per-voice.** The filter processes **per voice** — each sounding voice runs + its own filter with its own envelope state. *(Settled by follow-up.)* +- **Label.** The user-facing deck-group label is **"Filter"**. "MM preamp" is + working shorthand for the DSP lineage, not UI text. *(Settled by + follow-up.)* - **Deck reorder.** The knob deck row is relaid out in signal-flow order: **pitch → filter → amp**. **Open questions.** -- **Mode list.** Is mode a discrete selector, and over exactly which entries? - "Resonant high and lowpass" confirms HP and LP; whether band-pass/notch or - multiple slopes exist depends on the Cortex-M4 source and Daniel's intent. -- **Ranges and units.** Cutoff range (Hz), Q range, and mod-amt range/polarity - (unipolar or bipolar?) are unstated — likely settled by the Cortex-M4 code - plus a Daniel call at implementation time. -- **Mod routing.** "Mod amt" presumably scales the filter AHDSR's modulation of - cutoff — confirm the target is cutoff and whether any other mod sources - (velocity, key-tracking) are in scope now or deferred. -- **Scope: per-zone or per-instance.** Existing playback parameters live - per-zone with Sample/Zone panel parity; VOICE and MASTER are per-instance - exceptions. Which side does the filter fall on? +- **Other mod sources.** Mod amt targeting cutoff is settled; whether any + other mod sources (velocity, key-tracking) are in scope now or deferred is + unstated. +- **Parameter storage: per-zone or per-instance.** Per-voice *processing* is + settled, but it is compatible with either storage side. Existing playback + parameters live per-zone with Sample/Zone panel parity; VOICE and MASTER are + per-instance exceptions. Which side do the filter *parameters* fall on? - **Neutral default.** Should the filter default to a bypass/neutral state so pre-existing saved instances (and freshly loaded captures) sound unchanged until the user engages it? -- **Display name.** Is "MM Preamp Filter" the user-facing label for the deck - group, or working shorthand? **Acceptance criteria.** @@ -149,11 +184,18 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. pipeline point: the filter acts on pitched (post-pitch-envelope) signal, and the amp envelope still shapes the filtered result (audible ordering: pitch → filter → amp). -- Mode, cutoff, Q, and mod-amt controls appear in a filter deck group; the - filter AHDSR gets the full item-1 treatment (curve inner dials, corner radio - switch, tertiary-purple overlay editing). +- Mode, cutoff, Q, and mod-amt controls appear in a deck group labeled + **"Filter"**; the filter AHDSR gets the full item-1 treatment (curve inner + dials, corner radio switch, tertiary-purple overlay editing). +- Cutoff sweeps the full audio spectrum on a log scale, from fully open to + fully closed; Q spans 0.1 → 10 with √2 at the control's center; mod amt at + −100% and at +100% each drive cutoff across the full range, from opposite + ends. - High Q audibly emphasizes the cutoff region (resonance) in both HP and LP modes. +- Two simultaneously sounding voices at different envelope phases are filtered + independently (per-voice processing is audible, not a shared instance-wide + filter). - The deck row reads pitch → filter → amp left-to-right. - A project saved before this change reopens sounding identical (pending the neutral-default confirmation above). @@ -180,6 +222,15 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. > for the velocity-amp transfer curve as well as the pitch, filter, and amp EGs > (if they are in spline mode). +**Daniel's follow-up (verbatim, 2026-07-28).** + +> dual state, save but inactive. Default Curve Spline is a smooth y=1-x. Gate +> mode is not available when the Spline is used, spline always covers the full +> sample length. The spline curves don't have to rise AND fall, they also +> aren't globally monotone, the default curve is a smooth downward slope over +> the length. all the soft points between any hard points will be +> smooth/monotone. + **Intent.** Offer a free-drawn alternative to every staged envelope: the user switches any EG from Staged to Spline mode and draws the contour directly, with the monotonic-spline machinery already proven by the velocity curve — enhanced @@ -189,6 +240,22 @@ so sharp corners are possible, not everything smoothed. - **Mode toggle per EG.** Every processor that has an envelope (pitch, filter, amp) can switch its Staged EG to a **Spline EG**. +- **Dual state — save but inactive.** Both the Staged and the Spline state are + persisted; switching modes keeps the inactive one **saved but inactive**. No + conversion, no discard — round-tripping Staged↔Spline restores the other + mode's shape untouched. *(Settled by follow-up.)* +- **Gate unavailable in Spline mode.** Gate mode is **not available while a + Spline EG is active**; the spline **always covers the full sample length** — + a pure time function over the sample, i.e. the Trigger/one-shot playback + model. *(Settled by follow-up.)* +- **Not globally monotone.** Spline contours **don't have to rise and fall and + are not globally monotone**; the monotone guarantee is per-segment — **all + soft points between any hard points are smooth/monotone** (no overshoot + between adjacent points). *(Settled by follow-up.)* +- **Default contour.** A new Spline EG defaults to a **smooth y = 1 − x** — a + smooth downward slope over the full sample length. (Read as the Spline-EG + default contour; not a change to the velocity→amp transfer curve's existing + default.) *(Settled by follow-up.)* - **Spline foundation.** The Spline EG is based on the monotonic splines used for the velocity curve. The spline algorithm is singly implemented and multi-referenced; the enhancement below applies to that one implementation @@ -210,30 +277,17 @@ so sharp corners are possible, not everything smoothed. **Open questions.** -- **Staged↔Spline toggle semantics.** What happens to a Staged EG's values - when toggling to Spline mode and back? (Convert the staged shape into an - initial spline? Keep two independent per-mode states? Discard?) Daniel has - not said — this materially shapes both UX and persistence and needs his - call. -- **Gate-mode sustain/release.** A Staged Gate envelope holds at Sustain and - releases on note-off; a spline contour drawn "over the full sample length" - is a pure time function. How do note-off, sustain, and looped playback map - onto a Spline EG in Gate mode? (Trigger/one-shot is the natural fit; Gate - needs a stated rule.) -- **Time axis.** Is the contour's time axis normalized to the sample length - (so it rescales with the sample) or absolute seconds? "Over the full sample - length" suggests sample-relative — confirm, especially for looped sustain. -- **Monotonicity meaning.** The velocity spline is monotone to prevent - overshoot on a transfer curve. An EG contour must rise and fall; presumably - "monotonic" here means the per-segment no-overshoot property (values never - exceed the segment's endpoints), not a globally monotone function — confirm - the intended guarantee. +- **Time-axis storage.** Full-sample coverage is settled ("spline always + covers the full sample length"); what remains is only whether the stored + contour is normalized to the sample length (so it rescales when a + different-length capture loads) or anchored some other way. - **Point-editing grammar.** Point add/delete gestures and any point-count bound are unstated. The velocity-curve popup already established right-click node delete — reuse of that grammar seems natural but is Daniel's call. -- **Interaction with item-1 curve dials.** In Spline mode, do the staged - segment knobs/inner dials go inert (shape comes wholly from the spline), or - is some hybrid intended? Presumably the former — confirm. +- **Staged controls while Spline is active.** Sound-wise the staged EG is + inactive in Spline mode (settled by dual-state). Still open at the UI level: + do the staged segment knobs/inner dials stay editable (editing the dormant + staged state) or go inert/greyed until the user switches back? **Acceptance criteria.** @@ -245,8 +299,14 @@ so sharp corners are possible, not everything smoothed. adjacent segment, and the discontinuous slope is audible where the modulation target makes it so (e.g. a pitch EG corner). - A contour of several segments joined at hard points plays back over the full - sample length exactly as drawn. + sample length exactly as drawn — including contours that rise and fall + freely (no globally-monotone restriction), with no overshoot between any + adjacent pair of points. +- A freshly created Spline EG shows the smooth y = 1 − x default contour. +- While a Spline EG is active, Gate mode is not selectable; the spline plays + as a pure time function over the full sample length. +- Staged↔Spline round-trip preserves both states: switch to Spline, draw, + switch back — the staged values are exactly as left; switch forward again — + the spline contour is exactly as drawn. Both survive save/reload. - The velocity→amp transfer-curve editor supports the same control-click hard/smooth toggle with identical rendering behavior. -- Staged↔Spline toggling behaves per the answered open question above (that - answer gates this item's completion definition). From 78a214b247adc857efc93fd2c1f707708dbf539e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 18:44:48 -0400 Subject: [PATCH 08/40] =?UTF-8?q?docs(phase-q):=20fold=20Q-W0=20sign-off?= =?UTF-8?q?=20into=20PLAN/CONTEXT=20=E2=80=94=20all=2059=20dispositions=20?= =?UTF-8?q?settled;=20Q-W2v=20wave,=20T4-18=20VST=20placement,=20~600=20ce?= =?UTF-8?q?iling=20+=20structural=20heuristics,=20W3=20riders=20(wav=5Fcod?= =?UTF-8?q?ec,=20ICaptureBackend,=20Q-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 223 +++++++++++++++++++++++++++++++------- PLAN.md | 313 ++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 431 insertions(+), 105 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index abaa99f..810316d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2301,7 +2301,10 @@ shell maps that pure result to a cursor. No cue logic in the shell; the shell on > findings report — a functional-correctness/algorithm-quality complement to the SOLID/naming audit > below. **Q-W1 is gated on Q-W0's triage being complete and Daniel signing off on each finding's > disposition** (fix-now vs. document-and-defer). Spec: §"The pre-restructure audit wave (Q-W0)" -> below. +> below. **STATUS (2026-07-28): Q-W0 COMPLETE and SIGNED OFF — all 59 dispositions approved; the +> Q-W1 sub-gate is satisfied once the six approved fix-now remediations land (in flight on +> `pq-w0-fixes`). The audit's plan reshape is folded into this spec: Q-W2 6→8 seams, NEW wave +> Q-W2v (parallel with Q-W2), Q-W3 3→4 hoists + riders, Q-W5 + the ext-state-loop dedupe.** ## What it is @@ -2330,6 +2333,21 @@ reinvented wheel or a numerically-fragile DSP path should be eliminated or consc tree. Bringing the code "into the realm of something I can stand to look at" is not only a matter of shape; it is also a matter of the code being *functionally sound*. +**STATUS (2026-07-28): Q-W0 is COMPLETE — audit run, triage done, sign-off given.** Four parallel +tracks (T1 DSP, T2 architecture, T3 env-coupled constants, T4 sizing + placement), **59 +findings**; report: `docs/product/code-quality-audit.md`, appendices: +`docs/product/audit-notes/q-w0-t{1..4}-*.md`. Daniel approved **every disposition as proposed** on +2026-07-28. Six findings are fix-now *in Q-W0* — **T1-01** (linked-lag stereo splice alignment), +**T1-03** (Preserve prime bound + immediate `freezeTail()`), **T1-09** (`declickR_` dead-state +removal, riding the `sampler_core` edits), **T2-01(a)** (provenance wire-cursor hardening +backport), **T3-01** (gain-ramp seconds), **T3-03** (fade-ceiling seconds) — in flight on branch +`pq-w0-fixes`; **the Q-W1 sub-gate is satisfied once they land** (each with its module's CTest +target green; the audible DSP fixes with a stated before/after listening check). All other +fix-nows are assigned to the wave that already opens their file and are recorded in the wave specs +below. **The Q-11 question is answered:** the correlation-aligned SOLA pitch engine is **sound — +no technique replacement (phase-vocoder / WSOLA) warranted**; every pitch finding is a bounded +in-technique fix or a documented operating limit. + **Audit scope — the named surfaces:** 1. **DSP / audio, close eye on pitch.** Assess *algorithm quality* — correctness, artifacts, @@ -2395,10 +2413,11 @@ the subsystem map off the folders. ReaSampler adopts the *pattern* (directory = adapted to its own most load-bearing invariant — the pure/shell split — as the top level (see below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not code. -## Settled decisions (Q-1 settled; Q-2..Q-6 recommended — see `docs/product/code-organization.md` §6) +## Settled decisions (Q-1/Q-10/Q-11 settled 2026-07-27; Q-5/Q-6/Q-8/Q-9 + the audit's §4 forks settled 2026-07-28 — REC history kept; see `docs/product/code-organization.md` §6 and `docs/product/code-quality-audit.md` §4) - **Q-1 — namespace letter. SETTLED: `Q` (Quality).** Point-id family `Q1..Qn`, wave prefixes - `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg). `O` (Organization) + `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg; + **`Q-W2v`**, the + VST god-module wave added at the Q-W0 sign-off, 2026-07-28). `O` (Organization) was set aside: the glyph reads ambiguously against zero in point ids, and "Organization" undersells a phase measured against a *quality* bar. - **Q-2 — JSON extraction in scope + first. REC: yes.** The 4× duplicated `Parser` is the largest @@ -2413,17 +2432,43 @@ below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not `audio`/`ui`/`reclaim`/`version`/`json`. Directory and namespace agree; a symbol's home is unambiguous from either. - **Q-5 — god-module split granularity. REC: to the audit's named seams, no finer.** Well-factored, - not atomized. -- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).** + not atomized. **SETTLED (Daniel, 2026-07-28): seams-by-responsibility with the Q-W0 T4 seam + lists adopted** — `bank_panel` 6→8 seams (+ `panel_layout`, `panel_drag`; T4-01), + `capture_orchestrator` further split with `capture_batch` (T4-02) — **and the ~600-line file + ceiling is an acceptance criterion on every split wave**: seams are the method, the ceiling is + the bar; arbitrary bisection to hit the number is rejected. One documented exception: + `sampler_core.cpp` stays whole (T4-14/T4-27). +- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).** **SETTLED + (Daniel, 2026-07-28): in scope, last wave, as planned.** - **Q-7 — naming rides the relocation waves, no dedicated naming wave. REC: yes** (forced once Q-3/Q-4 settle — a rename is near-free during relocation, near-pure-churn standalone). - **Q-8 — class/module renames beyond the free namespace fix. REC: fix the two that actively mislead** — `BankIndex`→`BankModel` (the `bank_model.h`/`BankIndex` file↔class word-mismatch) and the unified JSON parser → `json::Reader`/`json::Writer` (or `json::Parser`) — **leave the merely-quirky** (`Book`/`Bank`/`Index`, `Sample`/`AudioSample`, `MinMax`, `KitBox`). Daniel's - to call. + to call. **SETTLED (Daniel, 2026-07-28): both renames** — `BankIndex`→`BankModel` (W1) and the + JSON parser minted as `json::Reader`/`json::Writer` (W1). From the audit, additionally: + **`ICaptureBackend` is deleted in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the + CLAUDE.md/CONTEXT "two backends behind one interface" correction **rides Q-W3's own commit**, + not earlier). - **Q-9 — align the `capture_realtime` (shell) / `realtime_record` (pure) word-order inversion. - REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle). + REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle). **SETTLED + (Daniel, 2026-07-28): yes** — the pure module takes the stem `capture_realtime`, the shell + takes the suffix (the `drag_out`↔`drag_out_win` model), during W3. +- **Audit §4a — VST placement. SETTLED (Daniel, 2026-07-28): T4-18** — `src/vst/` integrates into + the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` + + `shell/instrument/`. One rule, no special case: a file's directory says whether it may touch a + *host* type (REAPER **or** VST3 SDK); the artifact boundary is a link-graph fact the sources + already straddle. The T4-19 artifact-first subtree was set aside. Directory map below updated. +- **Audit §4e — WAV/RIFF consolidation moment. SETTLED (Daniel, 2026-07-28): a named rider on + Q-W3** — one pure **`wav_codec`** owner (chunk walker + layout + build + patch), absorbing the + T4-10 ingest extraction (T2-08 / T4-23 / T4-10). The dedup-by-hash / null-test maintenance + surface gets exactly one implementation. +- **Audit §4f — Q-W2v scheduling. SETTLED (Daniel, 2026-07-28): parallel with Q-W2** (different + artifact, zero file overlap); the serial "Q-W7" alternative was set aside. +- **Audit §4b/§4c/§4d — the Q-W0 fix-now set. SETTLED (Daniel, 2026-07-28): all approved + fix-now** — T1-01 + T1-03 (with T1-09 riding), T2-01(a), T3-01 + T3-03; in flight on + `pq-w0-fixes`; landing them closes Q-W0 and opens Q-W1. ## The directory + namespace map (Q-3 / Q-4) @@ -2434,24 +2479,42 @@ directories. - `core/model/` (`::model`) — `bank_model`, `bank_book`, `owned_manifest`, `provenance` - `core/view/` (`::view`) — `view_mode_model`, `view_tree`, `lane_keys`, `mode_switch` - `core/capture/` (`::capture`) — `render_settings`, `batch_capture`, `tail_control`, - `capture_paths`, `wav_trim`, `insert_plan` + `capture_paths`, `wav_trim`, `insert_plan`; **post-Q-W3** `wav_codec` (the one pure WAV/RIFF + owner — audit §4e rider) - `core/audio/` (`::audio`) — `peaks` - `core/ui/` (`::ui`) — `theme`, `component_geometry`, `bank_grid`, `tab_strip`, `action_buttons`, `prune_button` - `core/reclaim/` (`::reclaim`) — `prune_reconcile` - `core/version/` (`::version`) — `app_version` -- `core/json/` (`::json`) — **NEW** — extracted parser/serializer (replaces the 4 duplicate - `Parser`s) +- `core/json/` (`::json`) — **NEW** — extracted parser/serializer, minted as + `json::Reader`/`json::Writer` (replaces the 5 duplicate hand-rolled decoders — the four + `Parser`s + `tail_control`'s, T2-02) +- `core/instrument/` — **T4-18 SETTLED (Daniel, 2026-07-28)** — the VST artifact's pure side + joins the one top split (directory = "may it touch a host type", REAPER *or* VST3 SDK): + - `core/instrument/engine/` — `sampler_core`, `pitch_shift`, `velocity_curve`, `master_gain` + - `core/instrument/map/` — `sample_map` (+ `component_state_io` post-Q-W2v), `bank_sync`, + `bridge_marshal`, `note_entry`, `trigger_seam` + - `core/instrument/ui/` — `editor_geometry`, `keyboard_strip`, `waveform_view`, + `capture_browser`, `browser_scroll`, `param_slider`, `knob_deck`, `curve_popup`, + `envelope_overlay`, `envelope_edit`, `embed_strip` **`shell/` (REAPER-facing — subdir by subsystem, namespace as house style prefers):** - `shell/capture/` — `capture`, `capture_realtime`, `provenance_shell`, `track_guid`, `item_read`; - **post-Q-W3** `capture_orchestrator`, `scope_resolve`, `realtime_lifecycle` + **post-Q-W3** `capture_orchestrator`, `capture_batch`, `scope_resolve`, `realtime_lifecycle`, + `capture_realtime_finalize` (with the Q-9 stem/suffix rename) - `shell/panel/` — `draw_kit`; **post-Q-W2** `panel_render`, `panel_thumbnails`, - `panel_audition`, `panel_input`, `panel_bank_ops`, `panel_window` (from `bank_panel`) + `panel_audition`, `panel_input`, `panel_layout`, `panel_drag`, `panel_bank_ops`, + `panel_window` (from `bank_panel` — eight seams, T4-01) - `shell/view/` — `view` - `shell/persist/` — **post-Q-W5** `session`, `ext_state_io`, `prune_fs` (from `persist`) - `shell/actions/` — `drag_out_win`; **post-Q-W4** `design_view_actions`, `bank_actions`, `prune_action` (from `actions`) +- `shell/instrument/` — `reaper_bridge`, `reasampler_embed`, `vst_entry`, `reasampler_vst.h` / + `reasampler_uid.h`; **post-Q-W2v** the editor TUs (`editor_session` / `editor_controls` / + `editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` / + `editor_input_browse_zone` / `editor_platform` — `editor_layout` hoists *pure* → + `core/instrument/ui/`, discharging T2-06) and the processor TUs (`processor_state` / + `processor_reload` / lifecycle+`process()`) **`app/`:** `main.cpp` (post-Q-W3: API-pointer ownership + `ReaperPluginEntry` + dispatch only). @@ -2459,37 +2522,83 @@ directories. unified `Parser` (`::json`) must not collide once flattened into granular namespaces; resolve by subsystem home. -## The god-module split seams (Q-5) +## The god-module split seams (Q-5 — SETTLED 2026-07-28: T4 seam lists adopted; ~600 ceiling is the bar) -Split each to the audit-validated seams, no finer: +Split each to the audit-validated seams, no finer — **and every shipped TU lands under the +~600-line ceiling** (the Q-5 settlement: seams are the method, the ceiling is the bar; arbitrary +bisection to hit the number is rejected; the one documented exception is `sampler_core.cpp`). +LOC figures updated to the Q-W0 T4 census (2026-07-28): -- **`bank_panel.cpp` (2424 LOC → `shell/panel/`, Q-W2):** `panel_render` (draw/paint) / - `panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot path, direct - call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / `panel_bank_ops` - (bank CRUD — the single owner W4 dedupes against) / `panel_window` (lifecycle + OS - drag-out/drop-target). Split the fat `bank_panel.h` per seam (I). -- **`main.cpp` (1762 LOC → hoist to `shell/capture/`, Q-W3):** `capture_orchestrator` - (`RunCapture`/`captureAndIndexOne`/`renderOffline`/batch/recapture/realtime `Run*`) / - `scope_resolve` (range/razor/track resolution + provenance assembly inputs) / - `realtime_lifecycle` (state machine + globals + selection guards). `FxBypassGuard` moves out - but **stays stack RAII** (precision-critical). `main.cpp` → `app/`, reduced to pointers + entry - + dispatch. -- **`actions.cpp` (981 LOC → `shell/actions/`, Q-W4):** `design_view_actions` / +- **`bank_panel.cpp` (3459 LOC → `shell/panel/`, Q-W2 — eight seams, T4-01):** `panel_render` + (draw/paint) / `panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot + path, direct call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / + **`panel_layout`** (toolbar/footer/menu rects + row/cluster builders + region geometry — new + seam) / **`panel_drag`** (the card-drag/hover state machine, pure mirror `card_drag` — new + seam) / `panel_bank_ops` (bank CRUD — the single owner W4 dedupes against) / `panel_window` + (lifecycle + OS drag-out/drop-target). Without the two new seams, `panel_render` (~700) and + `panel_input` (~800) would ship over the ceiling. Split the fat `bank_panel.h` per seam (I). +- **`main.cpp` (1897 LOC → hoist to `shell/capture/`, Q-W3 — four hoists, T4-02):** + `capture_orchestrator` (`RunCapture`/`captureAndIndexOne`/`renderOffline`/single-capture + + realtime/insert action bodies — lands ~450) / **`capture_batch`** (batch family + + `RunRecaptureFromSource` + the two RAII selection guards — new hoist; recapture is + planner-driven like batch and shares the guard machinery) / `scope_resolve` (range/razor/track + resolution + provenance assembly inputs) / `realtime_lifecycle` (state machine + globals). + `FxBypassGuard` moves out but **stays stack RAII** (precision-critical). `main.cpp` → `app/`, + reduced to pointers + entry + dispatch. **Wave riders (SETTLED 2026-07-28):** delete + `ICaptureBackend` + correct the CLAUDE.md/CONTEXT description in the same commit (T4-26); + shared `stampCaptureSample` epilogue dedupe (T2-09); `capture_realtime_finalize` split with + the Q-9 rename (T4-08); `makeUniqueTag` per-session monotonic counter (T1-11); the pure + `wav_codec` consolidation (audit §4e). +- **`actions.cpp` (1016 LOC → `shell/actions/`, Q-W4 — T4-03: seams unchanged, still + sub-600):** `design_view_actions` / `bank_actions` / `prune_action` (`doBankPruneFolder` — the single file-deletion action). Dedupe `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`. -- **`persist.cpp` (766 LOC → `shell/persist/`, Q-W5):** `session` (lifecycle/poll + +- **`persist.cpp` (852 LOC → `shell/persist/`, Q-W5 — T4-04: seams unchanged; the pS-usage + growth landed exactly where this wave isolates it):** `session` (lifecycle/poll + `BeginLoadProjectState` reload hook) / `ext_state_io` (serialization bridge + GUID minting + folder relocation) / **`prune_fs`** (prune scan + `deleteOrphanFile` via `SHFileOperationW` — - the isolated single file-deletion authority). + the isolated single file-deletion authority). **Wave rider (T2-04):** the `GetProjExtState` + grow-loop ×3 dedupes onto `bridge_marshal`'s generalized retry policy — `usage_scan`'s + prune-safety-adjacent copy included. +- **`reasampler_editor.cpp` (3065 LOC — the largest file in the repo → `shell/instrument/`, + Q-W2v — eight TUs, T4-11; split axis = the Sample/Browse/Zone face structure):** + `editor_session` / `editor_controls` / `editor_layout` (**pure-candidate hoist** → + `core/instrument/ui/`, `editor_geometry` the named owner — discharges T2-06's + stranded-layout-math finding) / `editor_paint_sample` / `editor_paint_browse_zone` / + `editor_input_sample` / `editor_input_browse_zone` / `editor_platform`. Rider: adopt the pure + `ThumbnailKey` on the VST side while the editor is open (T2-10). +- **`reasampler_processor.cpp` (1164 LOC → `shell/instrument/`, Q-W2v — three TUs, T4-12):** + `processor_state` / `processor_reload` / lifecycle+`process()` kept whole. **No virtual seam + on the atomic-pointer-swap reload pattern (T4-29).** +- **`sample_map` (970 LOC + 708-line header → Q-W2v, T4-13 ≡ T2-07):** resolution core vs the + **`component_state_io`** binary ComponentState codec (+ matching header split) — the codec + grows every envelope bump (v6→v11 in one quarter); the extension stops linking the whole voice + engine to serialize one preset blob. The `core/wire` LE byte-codec template + (`putLE`/`readLE`, T4-20) lands with it. +- **`sampler_core.cpp` (968 LOC + 762-line header — Q-W2v, T4-14/T4-27): the TU stays WHOLE — + the documented hot-path exception to the ~600 ceiling.** Envelope `tick()`s run + per-voice-per-sample; same-TU definition is what lets the compiler inline the stack (no LTO in + the build); a by-class split is the exact heuristic-(3) dispatch blowout. The header splits + into `zone_params.h` + `sampler_core.h`. Recorded here so nobody "fixes" it later. ## The JSON extraction (Q-2 / Q-W1) -Extract one pure **`core/json`** (`::json`): parser (`parseString`/`parseInt`/`parseKey`/ +Extract one pure **`core/json`** (`::json` — minted as **`json::Reader`/`json::Writer`**, Q-8 +SETTLED 2026-07-28): parser (`parseString`/`parseInt`/`parseKey`/ `skipValue` + escape) + serialize/emit helpers. Rewire `bank_model`, `bank_book`, -`view_mode_model`, `owned_manifest` onto it and **delete their four hand-rolled `Parser`s**. +`view_mode_model`, `owned_manifest`, **and `tail_control` (T2-02 — the fifth hand-rolled decoder +the §2 audit undercounted)** onto it and **delete all five hand-rolled decoders**. Round-trip output must be **byte-identical** to before — this is a structural dedupe, not a format change. Off all hot paths (serialization runs at save/load, never per frame) — safe to abstract -freely. +freely. **W1 siblings (from the Q-W0 sign-off):** the shared `readFileBytes` pure helper +(T2-03); the length-prefixed wire-`Cursor` collapse into one shared wire codec beside +`core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` / +`parseBankGeneration` (T2-01(b) — the structural half; the hardening backport lands in Q-W0); +the **concrete** `ui::Rect` unification + `contains()` + per-role aliases, retiring the +XYWH-vs-LTRB fork — NOT a template (T2-05 ≡ T4-21), with the `clamp01` rider (T4-24) and the +`hitIndex` template only as an opportunistic follow-on (T4-22); the `slot_map` extraction +(T4-05) and optional `view_mode_model` planner split (T4-06) riding files W1 already opens; the +relocation scope grows to the ~20 clean VST pure libs under `core/instrument/` (T4-18). ## The OCP registration-table (Q-6 / Q-W6) @@ -2515,7 +2624,7 @@ focus. The audit (2026-07-27) is grep-verified; the load-bearing findings: one `json::Parser` in Q-W1; the shared pure-UI rect types `FooterRect` / `ButtonRect` (defined in `prune_button.h`, reused by `footer_bar.h` under an explicit hand-collision "NAME NOTE") get one `ui::` owner; `Sample` (`model::`) vs `AudioSample` (`audio::`) de-collide by home. -- **Genuine renames (Q-8/Q-9 — Daniel's call):** `BankIndex`→`BankModel` (the `bank_model.h` +- **Genuine renames (Q-8/Q-9 — SETTLED, Daniel 2026-07-28: all land):** `BankIndex`→`BankModel` (the `bank_model.h` file↔class word-mismatch — the worst legibility wart, rec: rename the class so the model family reads `BankModel`/`BankBook`/`ViewModeModel`); the unified JSON parser named `json::Reader`/ `json::Writer` at W1 mint; align `capture_realtime`(shell)/`realtime_record`(pure) to the house @@ -2550,6 +2659,25 @@ the following are acceptance criteria on every point: **Net:** every recommended split falls on a cold path or preserves call/inline shape on the two hot ones. *A split that would add a hot-path indirection is out of scope — rework it or drop it.* +## Structural heuristics (Daniel, 2026-07-28 — acceptance criteria phase-wide) + +Three heuristics postdate the original framing and bind every wave. They **generalize** the +three-hot-path performance guardrail above — they do not replace it: + +1. **More directories is a must; more files is good; ~600-line file ceiling.** SRP applies to + namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's + named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an + arbitrary bisection (bisection-to-hit-the-number is rejected). A documented hot-path + exception (`sampler_core.cpp`, T4-14/T4-27) is legitimate; silent overshoot is not. +2. **Templates are the right tool for compile-time dedup — use them where earned.** The LE + byte-codec `putLE`/`readLE` (T4-20) is earned: compile-time dispatch, zero runtime cost, off + the hot paths. The rect family is NOT (T4-21): the types differ in name only, so one + **concrete** `ui::Rect` — a template there would model nothing. +3. **SOLID is great, but saved CPU is better.** No dispatch-stack blowouts *anywhere* — not just + the three named hot paths; prefer static polymorphism where the types are compile-time-known. + The T4-27 warning is the canonical case: a by-class `sampler_core` split would put virtual + envelope `tick()`s on the per-voice-per-sample path — exactly the blowout this forbids. + ## The GATE (load-bearing — Phase Q is last) Phase Q is **gated on the tree being otherwise quiescent.** Daniel's plain readiness target: @@ -2583,16 +2711,27 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered: pre-restructure audit wave (Q-W0)"). Produces a written, triaged findings report; runs **first** and **gates Q-W1** — no structural point begins until the triage closes and Daniel signs off on every disposition. Fix-now findings are remediated here or folded into the wave that opens the - file; the report may add/reshape downstream Q-W1..Q-W6 points before they start. -- **Q-W1** — safe opener: `core/json` extract (delete 4 `Parser`s) + impose the directory/ + file; the report may add/reshape downstream Q-W1..Q-W6 points before they start. **COMPLETE + and signed off 2026-07-28; the sub-gate closes when the six fix-now remediations land + (`pq-w0-fixes`).** +- **Q-W1** — safe opener: `core/json` extract (delete the 5 hand-rolled decoders — T2-02 adds + `tail_control`) + impose the directory/ namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change). All later waves assume this layout. **Carries the naming collision fixes + the model-class - renames (Q-8), which are free during this relocation.** -- **Q-W2..Q-W5** — the four god-module splits, one per wave, risk-ordered (`bank_panel` → - `main.cpp` → `actions.cpp` → `persist.cpp`). Q-W4 depends on Q-W2 (`panel_bank_ops` dedupe - target); Q-W5 best after Q-W4 (`prune_action` → `prune_fs` routing); otherwise parallel-safe. + renames (Q-8), which are free during this relocation.** **Scope grown by Q-W0 (2026-07-28):** + + `readFileBytes` (T2-03), the wire-`Cursor` codec (T2-01(b)), the concrete `ui::Rect` + unification (T2-05 ≡ T4-21) + `clamp01` (T4-24), the `slot_map`/planner riders (T4-05/T4-06), + and the ~20 clean VST pure libs under `core/instrument/` (T4-18). +- **Q-W2..Q-W5 (+ Q-W2v)** — the god-module splits, risk-ordered (`bank_panel` → + `main.cpp` → `actions.cpp` → `persist.cpp`), plus **Q-W2v** (NEW, added at the Q-W0 sign-off: + the VST god-modules — editor eight TUs / processor three TUs / `component_state_io`; + `sampler_core` TU whole, the documented exception), which **runs parallel with Q-W2** + (different artifact, zero file overlap — audit §4f SETTLED). Q-W4 depends on Q-W2 + (`panel_bank_ops` dedupe target); Q-W5 best after Q-W4 (`prune_action` → `prune_fs` routing); + otherwise parallel-safe. **Q-W2 carries the `panel_*` names; Q-W3 carries the `capture_realtime`/`realtime_record` - word-order fix (Q-9).** + word-order fix (Q-9) + the settled riders (`ICaptureBackend` deletion + doc correction, + `wav_codec`, `stampCaptureSample` dedupe, T1-11, `capture_realtime_finalize`).** - **Q-W6** — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration code isolated first). Sequenced last; most droppable if narrowing. - **Naming (Q-7): no dedicated wave** — every rename rides the wave already relocating/splitting @@ -2624,7 +2763,9 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered: realtime-tick — ever (a hard acceptance bar, not advice). - **No design-level (D-letter SOLID) rework.** `main`/`bank_panel` depending on concrete capture backends is a low-priority Dependency-Inversion concern — **out of scope** (a design change, not - a reorg). Phase Q reorganizes; it does not re-architect interfaces. + a reorg). Phase Q reorganizes; it does not re-architect interfaces. (Deleting the *dead* + `ICaptureBackend` abstraction in Q-W3 is the opposite move — removing a false interface with + one deriver and zero polymorphic call sites, T4-26 — and is in scope.) - **No `peaks` data-ownership change.** `peaks` forcing a whole-file `std::vector` copy on the thumbnail path is noted but **not touched** — reworking it risks the hot path. - **No big-bang commit.** Every wave is independently landable and CTest-green; reject a change set @@ -2632,5 +2773,7 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered: - **Do not begin before the GATE.** Re-confirm the tree is quiescent (Phase S + L + D2 merged/closed; M9 abandoned) before any Q point. **And do not begin any structural point (Q-W1+) before the Q-W0 sub-gate:** the audit's triage is complete and Daniel has signed off on every finding's disposition. + (Sign-off given 2026-07-28; the sub-gate now closes when the six fix-now remediations land on + `pq-w0-fixes`.) - **Verify** the CMake `src/` path updates and the SWELL/LICE surfaces still resolve after relocation, as the existing build already requires. diff --git a/PLAN.md b/PLAN.md index 7d52c69..b907ed6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -220,8 +220,21 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > downstream Q-W1..Q-W6 points; fixes that Q-W0 classifies fix-now are remediated in Q-W0 (or folded > into the wave that already touches the file), **not** deferred silently into the structural waves. > +> **Q-W0 SIGN-OFF: COMPLETE (Daniel, 2026-07-28).** The audit ran as four parallel tracks (T1 DSP, +> T2 architecture, T3 env-coupled constants, T4 sizing/placement — **59 findings**; report +> `docs/product/code-quality-audit.md`, appendices `docs/product/audit-notes/`), and **all 59 +> findings' dispositions are approved as proposed.** The Q-W1 sub-gate is satisfied **once the six +> approved fix-now remediations land** (in flight on branch `pq-w0-fixes`, Q-W0-scoped): T1-01, +> T1-03, T1-09, T2-01(a), T3-01, T3-03. The audit's §3 plan reshape is **folded into the waves +> below** (Q-W2 6→8 seams; NEW wave **Q-W2v** parallel with Q-W2; Q-W3 3→4 hoists + riders; Q-W5 +> + the ext-state-loop dedupe), and its §4 decision list is settled — see the settlement block +> below. The Q-11 question is answered by the audit: the SOLA pitch engine is **sound — no +> technique replacement warranted**; every pitch finding is a bounded in-technique fix or a +> documented operating limit. +> > **Settled (Q-1, this-doc):** the phase is **`Q` (Quality)**; point-id family `Q1..Qn`, wave -> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg). +> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg; **+ +> `Q-W2v`**, the VST god-module wave added at the Q-W0 sign-off, 2026-07-28). > **Settled (Q-10/Q-11, Daniel 2026-07-27):** Q-10 audit-report home = a **committed doc** > (`docs/product/code-quality-audit.md`, not a tracked issue list); Q-11 pitch-remediation depth = > **defer to findings** (default document-and-defer; weigh a bounded OLA fix before a technique @@ -238,6 +251,25 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > `BankModel`; the JSON `Parser`→`json::Reader`/`Writer`), leave the merely-quirky (rec); > Q-9 align the `capture_realtime`/`realtime_record` shell↔core word order during W3 (rec: yes).** > +> **SETTLED (Daniel, 2026-07-28 — with the Q-W0 sign-off; the REC record above kept as history):** +> **Q-5 SETTLED** — split to **seams-by-responsibility with the T4 seam lists adopted** +> (`bank_panel` 6→8 seams adding `panel_layout` + `panel_drag`, T4-01; `capture_orchestrator` +> further split with `capture_batch`, T4-02), and the **~600-line file ceiling is an acceptance +> criterion on every split wave** — seams are the method, the ceiling is the bar; arbitrary +> bisection to hit the number is rejected. **Q-6 SETTLED: in scope, last wave, as planned.** +> **Q-8 SETTLED: both renames** — `BankIndex`→`BankModel` (W1) and the JSON parser minted as +> `json::Reader`/`json::Writer` (W1); additionally from the audit, **`ICaptureBackend` is deleted +> in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the CLAUDE.md/CONTEXT "two +> backends behind one interface" correction **rides Q-W3 itself**, recorded as a rider — the docs +> are not edited before that wave). **Q-9 SETTLED: yes** — align to stem `capture_realtime`, +> shell suffixed, during W3. **VST placement (audit §4a) SETTLED: T4-18** — `src/vst/` integrates +> into the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` + +> `shell/instrument/` (Q-3 directory map updated in CONTEXT.md §Phase Q). **WAV/RIFF +> consolidation (audit §4e) SETTLED:** a named rider on **Q-W3** — one pure **`wav_codec`** owner +> (walker + layout + build + patch), absorbing the T4-10 ingest extraction. **Q-W2v scheduling +> (audit §4f) SETTLED: parallel with Q-W2** (different artifact, zero file overlap; the serial +> "Q-W7" alternative set aside). +> > **HARD CONSTRAINT — performance (see CONTEXT.md §Phase Q, `docs/product/code-organization.md` > §3).** The reorg must cost **zero runtime.** On the three hot paths — `peaks` envelope > compute, audition/preview, the realtime-capture tick — **no added virtual dispatch, no @@ -247,6 +279,21 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > an acceptance criterion on every point: *a split that would add a hot-path indirection is out > of scope — rework it or drop it.* > +> **STRUCTURAL HEURISTICS (Daniel, 2026-07-28 — acceptance criteria on every wave; these +> *generalize* the three-hot-path guardrail above, they do not replace it):** +> (1) **More directories is a must, more files is good, ~600-line file ceiling** — SRP applies to +> namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's +> named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an +> arbitrary bisection; a documented hot-path exception (`sampler_core.cpp`, T4-14/T4-27) is +> legitimate, silent overshoot is not. +> (2) **Templates are the right tool for compile-time dedup — use them where earned** (the LE +> byte codec `putLE`/`readLE`, T4-20), not for name-only unification (the rect family is one +> **concrete** `ui::Rect`, NOT a template — T4-21's ruling). +> (3) **SOLID is great but saved CPU is better** — no dispatch-stack blowouts *anywhere*, not +> just the three named hot paths; prefer static polymorphism where types are compile-time-known +> (T4-27's warning is the canonical case: a by-class `sampler_core` split would put virtual +> envelope `tick()`s on the per-voice-per-sample path). +> > **NAMING dimension (added 2026-07-27; grep-verified audit in `docs/product/code-organization.md` > §2b).** Beyond giving symbols a directory + namespace *home* (Q-3/Q-4), Phase Q also gives > poorly/inconsistently-named symbols a consistent *name*, against the same Vital bar. The audit @@ -265,9 +312,14 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > per-module static-lib + per-module test-executable seams already draw the module boundaries; > a file move + namespace change is mechanically verifiable — `ctest --test-dir build` is green > or it isn't. **Green-CTest-at-every-point is an acceptance criterion.** Big-bang is rejected; -> the reorg is risk-ordered waves (W1 safe opener → W2–W5 god-module splits → W6 OCP finish). +> the reorg is risk-ordered waves (W1 safe opener → W2/W2v–W5 god-module splits → W6 OCP finish). ## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1) +**STATUS (2026-07-28): audit COMPLETE, triage COMPLETE, sign-off COMPLETE.** The findings report +is committed (`docs/product/code-quality-audit.md`; track appendices in +`docs/product/audit-notes/` — T1 DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 +findings). Daniel approved every disposition 2026-07-28. **The Q-W1 sub-gate is satisfied once +the six approved fix-now remediations land** (final point below; in flight on `pq-w0-fixes`). **Goal:** Before a single structural point moves, perform a **thorough static/functional audit** of the codebase and produce a **written, triaged findings report**. This is the *functional-correctness and algorithm-quality* complement to the grep-verified SOLID/naming audit that already grounds @@ -322,19 +374,33 @@ before/after listening or null check. **The gate to Q-W1 is: triage complete + D are a decision, not an omission. - [ ] **Sign-off gate.** Daniel reviews the triage and signs off on each disposition. Q-W1 does not begin until this is done; fold any new/reshaped downstream points the audit surfaces into - Q-W1..Q-W6 before starting them. + Q-W1..Q-W6 before starting them. **DONE (Daniel, 2026-07-28): all 59 dispositions approved as + proposed; the §3 plan reshape and §4 decisions are folded into Q-W1..Q-W6 + Q-W2v below.** +- [ ] **Fix-now remediations (approved 2026-07-28; Q-W0-scoped; in flight on branch + `pq-w0-fixes`):** T1-01 linked-lag stereo splice alignment, T1-03 Preserve prime bound + + immediate `freezeTail()`, T1-09 `declickR_` dead-state removal (rider on the `sampler_core` + edits), T2-01(a) provenance wire-cursor hardening backport, T3-01 gain-ramp seconds, T3-03 + fade-ceiling seconds. Each lands with its module's CTest target green; the audible DSP fixes + with a stated before/after listening check. **Landing these closes Q-W0 and opens Q-W1.** ## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules **Goal:** The zero-god-module-risk opener. Two moves: (1) extract a pure **`core/json`** module -(parser + serializer) and **delete the four hand-rolled `Parser`s** in `bank_model` / -`bank_book` / `view_mode_model` / `owned_manifest` (the single largest DRY+SRP violation, and -entirely off the hot paths); (2) impose the settled `core/`/`shell/`/`app/` directory layout + -sub-namespaces (`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`) on -the **30 clean pure libs + the clean shells that need no splitting** — pure relocation, no logic -change. Proves the wave discipline (relocate + encapsulate, CTest-green) before any god-module -surgery. CONTEXT.md §Phase Q (json extraction; directory + namespace map). -**Verify:** CTest green at every commit. The four duplicate `Parser`s are gone, replaced by one -`core/json` consumed by all four models; round-trip serialization is byte-identical to before +(parser + serializer) and **delete the five hand-rolled JSON decoders** — the four `Parser`s in +`bank_model` / `bank_book` / `view_mode_model` / `owned_manifest` **plus `tail_control`'s fifth +decoder** (T2-02, Q-W0's undercount fix — the wave's one-JSON-path goal is not met without it) — +the single largest DRY+SRP violation, entirely off the hot paths; (2) impose the settled +`core/`/`shell/`/`app/` directory layout + sub-namespaces +(`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json` + the `instrument` +family) on the **30 clean pure libs + the clean shells that need no splitting + the ~20 clean +VST pure libs** (T4 §1.5, under the settled T4-18 `core/instrument/{engine,map,ui}` + +`shell/instrument/` shape) — pure relocation, no logic change. **Q-W0 additions (SETTLED +2026-07-28):** the shared `readFileBytes` pure helper (T2-03); the wire-`Cursor` collapse into +one shared codec beside `core/json` (T2-01(b)); the rect unification — one **concrete** +`ui::Rect` + `contains()` + per-role aliases, NOT a template (T2-05 ≡ T4-21). Proves the wave +discipline (relocate + encapsulate, CTest-green) before any god-module surgery. CONTEXT.md +§Phase Q (json extraction; directory + namespace map). +**Verify:** CTest green at every commit. The five duplicate JSON decoders are gone, replaced by +one `core/json` consumed by all five consumers; round-trip serialization is byte-identical to before (no format change — a *structural* dedupe, not a behavior change). Every relocated clean module compiles and its test executable passes unmoved. `Sample` (model) vs `AudioSample` (audio) vs unified `Parser` (json) do not collide once sub-namespaced. No REAPER type crosses into any @@ -345,81 +411,174 @@ any fix-now findings the audit assigned to Q-W1 folded in). First structural wav - [ ] Extract `core/json` (pure parser + serializer: parseString/parseInt/parseKey/skipValue + escape, plus emit helpers); unify under `reasampler::json`; guard the `Parser` name against cross-lib collision. Off all hot paths — safe to abstract freely. -- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest` onto `core/json`; - **delete the four duplicate `Parser`s.** Round-trip output byte-identical (dedupe, not - reformat). +- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest`, **and + `tail_control` (T2-02)** onto `core/json`; **delete the five duplicate decoders.** Round-trip + output byte-identical (dedupe, not reformat). +- [ ] Add the shared `readFileBytes` pure helper to the `core/` utility home; both artifacts + link it (T2-03). +- [ ] Collapse the length-prefixed wire-`Cursor` family into one shared wire codec beside + `core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` / + `parseBankGeneration` (T2-01(b) — the structural half; the hardening backport lands in Q-W0). +- [ ] Riders on files this wave already opens: `slot_map` extraction from `bank_book` (T4-05); + `view_mode_model` planner split (T4-06 — optional if the wave wants to stay minimal); + `view_lanes` split only if relocation touches `view.cpp` anyway (T4-09). - [ ] Relocate the 30 clean pure libs into `core/{model,view,capture,audio,ui,reclaim,version, - json}/` and the clean shells into `shell/{capture,panel,view,persist,actions}/`; move - `main.cpp` to `app/`. Update `CMakeLists.txt` `src/` paths only (no target-graph change). + json}/` **+ the ~20 clean VST pure libs into `core/instrument/{engine,map,ui}/` (T4-18 + SETTLED, Daniel 2026-07-28)**, and the clean shells into + `shell/{capture,panel,view,persist,actions,instrument}/`; move `main.cpp` to `app/`. Update + `CMakeLists.txt` `src/` paths only (no target-graph change). - [ ] Apply sub-namespaces matching the directories on every relocated *clean* module (the - god-modules re-namespace their own new TUs as they split, W2–W5). Resolve `Sample`/ + god-modules re-namespace their own new TUs as they split, W2/W2v–W5). Resolve `Sample`/ `AudioSample`/`Parser` homes. **This alone resolves the naming *collisions*** (§2b.2): the shared pure-UI rect types (`FooterRect`/`ButtonRect`/`Selection`/`CellRect`) get one `ui::` - owner — retire the hand-collision "NAME NOTE" in `footer_bar.h`. -- [ ] **Naming riders (Q-8, if settled):** rename the survivor JSON parser to `json::Parser` - (or `json::Reader`/`json::Writer`); if Daniel takes the `BankIndex`→`BankModel` rename, land - it here (mechanical class rename, verified by `bank_model_tests`). No rename on a file this - wave isn't already relocating (Q-7). + owner — one **concrete** `ui::Rect` + `contains()` + per-role aliases, retiring the + XYWH-vs-LTRB fork and folding in `editor_geometry`'s `Rect` (T2-05 ≡ T4-21 — explicitly NOT a + template); retire the hand-collision "NAME NOTE" in `footer_bar.h`. Riders: `clamp01` dedup + (T4-24); the `hitIndex` hit-test template only as an opportunistic follow-on once the rect + unification lands (T4-22). +- [ ] **Naming riders (Q-8 — SETTLED, Daniel 2026-07-28: both renames):** the survivor JSON + parser is minted as **`json::Reader`/`json::Writer`**; land **`BankIndex`→`BankModel`** here + (mechanical class rename, verified by `bank_model_tests`). No rename on a file this wave isn't + already relocating (Q-7). - [ ] Confirm CTest green + no hot-path change: `peaks`/audition/realtime-tick untouched by this wave (pure relocation of clean modules; `peaks` stays a free function). -## Q-W2 — split `bank_panel.cpp` (the biggest god-module, 2424 LOC) -**Goal:** Split the largest god-module (8+ responsibilities) along the audit's named seams: +## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) +**Goal:** Split the largest extension god-module (8+ responsibilities) along the audit's named +seams — **eight TUs (Q-5 SETTLED with the T4-01 reshape, Daniel 2026-07-28)**: `panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / -`panel_window`. Split the fat `bank_panel.h` alongside (Interface Segregation). **Preserve the +`panel_window` **+ `panel_layout` (toolbar/footer/menu rects + row/cluster builders + region +geometry glue) + `panel_drag` (the card-drag/hover state machine — it already has a pure mirror, +`card_drag`)** — without the two new seams, `panel_render` (~700) and `panel_input` (~800) would +ship over the ~600 ceiling on day one. Split the fat `bank_panel.h` alongside (Interface +Segregation). **Preserve the audition hot path as a direct call-through, never virtual.** `panel_bank_ops` becomes the single home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md §Phase Q (bank_panel split seams; hot-path audition guardrail). See `docs/product/code-organization.md` §2.1, §5. -**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/`; the panel +**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/` and +**every TU lands under the ~600-line ceiling** (the Q-5 acceptance bar); the panel draws, thumbnails, auditions, handles input, does bank ops, and manages its window exactly as before (no behavior change — verify in DAW that the panel is visually and interactively unchanged). Audition/preview call path stays a **direct call-through** (no virtual dispatch, no added header→TU indirection on the preview path). The ~20-function public API is now segmented across the split headers. **Depends on:** Q-W1 (directory/namespace layout established). Independently landable. +**Parallel-safe with Q-W2v** (different artifact, zero file overlap — §4f SETTLED). - [ ] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache → - `panel_thumbnails`. + `panel_thumbnails`; toolbar/footer/menu rect + row/cluster builders + region geometry → + **`panel_layout`** (new seam, T4-01). - [ ] Split the audio audition/preview engine → `panel_audition` — **direct call-through, not virtual; preview idle path unchanged.** - [ ] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window - lifecycle + OS drag-out/drop-target → `panel_window`. + lifecycle + OS drag-out/drop-target → `panel_window`; the card-drag/hover state machine → + **`panel_drag`** (new seam, T4-01). Per-mouse-move work stays plain free-function calls + (T4-28). - [ ] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes `actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I). -- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added. +- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added; all eight TUs + under the ~600 ceiling. + +## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2) +**Goal:** Close the audit's structural scope gap: the VST artifact's god-modules had no owning +wave, and `reasampler_editor.cpp` (3065 LOC) is the largest file in the repo. Split the editor +into **eight TUs along the Sample/Browse/Zone face axis** (T4-11): `editor_session` / +`editor_controls` / `editor_layout` (**pure-candidate hoist** into the existing pure homes — +`editor_geometry` is the named owner; this discharges T2-06's stranded-layout-math finding) / +`editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` / +`editor_input_browse_zone` / `editor_platform`. Split `reasampler_processor.cpp` (1164 LOC) into +**three TUs** (T4-12): `processor_state` / `processor_reload` / lifecycle+`process()` kept +whole. Split `sample_map` into resolution core vs the **`component_state_io`** binary codec + +matching header split (T4-13 ≡ T2-07 — the codec grows every envelope bump; the extension stops +linking the whole voice engine to serialize one preset blob). **`sampler_core.cpp` stays whole +(968 LOC) — a DOCUMENTED hot-path exception to the ~600 ceiling** (T4-14/T4-27: envelope +`tick()`s run per-voice-per-sample; same-TU definition is what lets the compiler inline the +stack, no LTO in the build; a by-class split is the exact heuristic-(3) dispatch blowout); its +header splits into `zone_params.h` + `sampler_core.h`. The `core/wire` LE byte-codec template +(`putLE`/`readLE`, T4-20) lands here with its biggest consumer. CONTEXT.md §Phase Q (VST split +seams; `sampler_core` exception). +**Verify:** CTest green at every commit. Editor and processor behave identically in DAW (visual ++ interactive parity across all three faces; `process()` audio unchanged). Every new TU lands +under the ~600-line ceiling **except the one documented `sampler_core.cpp` exception**. +`process()` + its per-block helpers stay one TU; the atomic-pointer-swap reload pattern gains +**no virtual seam** (T4-29); no dispatch-stack blowout anywhere (heuristic 3). +**Depends on:** Q-W1 (layout + the T4-18 `instrument/` placement established). **Parallel-safe +with Q-W2** (different artifact, zero file overlap — §4f SETTLED, Daniel 2026-07-28; the serial +"Q-W7" alternative was set aside). + +- [ ] Split `reasampler_editor.cpp` → the eight face-axis TUs; hoist `editor_layout`'s pure + geometry into the existing pure homes (`editor_geometry` — discharges T2-06). +- [ ] Split `reasampler_processor.cpp` → `processor_state` / `processor_reload` / + lifecycle+`process()` whole; **no virtual seam on the atomic-swap pattern** (T4-29). +- [ ] Split `sample_map` → resolution core + `component_state_io` codec (+ header split); the + extension's preset-blob path stops linking the voice engine (T4-13 ≡ T2-07). +- [ ] `sampler_core`: split `zone_params.h` out of the header; **TU stays whole — documented + exception** (T4-14/T4-27), recorded in the wave brief so nobody "fixes" it later. +- [ ] Land the `core/wire` LE byte-codec template (`putLE`/`readLE`, T4-20) with + `component_state_io`; other consumers rewire opportunistically. +- [ ] Rider: adopt the pure `ThumbnailKey` on the VST editor side (T2-10). +- [ ] Verify in DAW: editor + processor unchanged; CTest green; ceiling met (one documented + exception); no added dispatch. ## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch) -**Goal:** Reduce `main.cpp` (1762 LOC) to its actual job — API pointers + `ReaperPluginEntry` + -dispatch (~the owns-pointers ~120 lines) — by hoisting: `capture_orchestrator` (`RunCapture` / -`captureAndIndexOne` / `renderOffline` / batch/recapture/realtime `Run*`), `scope_resolve` +**Goal:** Reduce `main.cpp` (1897 LOC at the Q-W0 census) to its actual job — API pointers + +`ReaperPluginEntry` + dispatch — by hoisting **four** TUs (T4-02 reshape, SETTLED with Q-5, +Daniel 2026-07-28 — the planned three left `capture_orchestrator` at ~885, over the ceiling): +`capture_orchestrator` (`RunCapture` / `captureAndIndexOne` / `renderOffline` / single-capture + +realtime/insert action bodies — lands ~450), **`capture_batch`** (the batch family + +`RunRecaptureFromSource` + the two RAII selection guards — recapture is planner-driven like +batch and shares the guard machinery), `scope_resolve` (`resolveRange`/`resolveRazorRange`/`collectSelectedTracks` + provenance assembly inputs), and -`realtime_lifecycle` (the realtime-capture state machine + globals + selection guards). +`realtime_lifecycle` (the realtime-capture state machine + globals). **`FxBypassGuard` moves out but stays a stack RAII object (precision-critical); the realtime idle -tick stays a single pointer test.** CONTEXT.md §Phase Q (main split seams; FxBypassGuard + +tick stays a single pointer test.** **Q-W0 riders owned by this wave (all SETTLED 2026-07-28):** +delete `ICaptureBackend` (T4-26 — one deriver, zero polymorphic call sites; `OfflineRenderBackend` +becomes concrete; the CLAUDE.md/CONTEXT "two backends behind one interface" correction **rides +this wave's own commit**, not earlier); the shared `stampCaptureSample` capture-epilogue dedupe +(T2-09); the `capture_realtime_finalize` split riding the Q-9 rename (T4-08); the `makeUniqueTag` +per-session monotonic-counter fix (T1-11); and the **WAV/RIFF consolidation (audit §4e)** — one +pure **`wav_codec`** owner (walker + layout + build + patch), absorbing `ingest.cpp`'s pure WAV +build helpers (T2-08 / T4-23 / T4-10). CONTEXT.md §Phase Q (main split seams; FxBypassGuard + realtime-tick guardrails). See `docs/product/code-organization.md` §2.1, §3. **Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves identically in DAW; the null test still nulls, bit-identical repeats still match (the precision invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*` path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test. -`main.cpp` is now pointers + entry + dispatch only. -**Depends on:** Q-W1. Independent of Q-W2. +`main.cpp` is now pointers + entry + dispatch only. The four hoisted TUs + `wav_codec` land +under the ~600 ceiling; the WAV/RIFF layout has **one** pure owner (the dedup-by-hash and +null-test invariants now rest on one implementation); `ICaptureBackend` is gone with no behavior +change and the CLAUDE.md/CONTEXT description is corrected in the same commit. +**Depends on:** Q-W1. Independent of Q-W2/Q-W2v. - [ ] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep `FxBypassGuard` a **stack RAII** object as it moves (precision-invariant-critical). +- [ ] Hoist the batch family + `RunRecaptureFromSource` + the two RAII selection guards → + **`capture_batch`** (fourth hoist, T4-02) so `capture_orchestrator` lands ~450. - [ ] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`. -- [ ] Hoist the realtime-capture lifecycle state machine + globals + the two RAII selection - guards → `realtime_lifecycle`; **idle tick stays a single pointer test.** +- [ ] Hoist the realtime-capture lifecycle state machine + globals → `realtime_lifecycle`; + **idle tick stays a single pointer test.** - [ ] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`. -- [ ] **Naming rider (Q-9, if settled):** align the `capture_realtime` (shell) / `realtime_record` - (pure) word-order inversion to the house shell↔core convention (rec: stem `capture_realtime`, - shell suffixed) — a free rider since W3 already hoists the realtime lifecycle. No rename on a - file this wave isn't already touching (Q-7). +- [ ] **Naming rider (Q-9 — SETTLED, Daniel 2026-07-28: yes):** align the `capture_realtime` + (shell) / `realtime_record` (pure) word-order inversion to the house shell↔core convention — + the pure module takes the stem `capture_realtime`, the shell takes the suffix + (`drag_out`↔`drag_out_win` is the model). Split `capture_realtime_finalize` (async lifecycle + vs file-side finalize) in the same surgery (T4-08). No rename on a file this wave isn't + already touching (Q-7). +- [ ] Delete `ICaptureBackend` (T4-26): `OfflineRenderBackend` becomes concrete; correct the + CLAUDE.md/CONTEXT "two backends behind one interface" description **in the same commit**. +- [ ] Dedupe the capture-stamp epilogue → shared `stampCaptureSample` (T2-09 — the divergent + bits stay in the realtime caller); fix `makeUniqueTag` with a per-session monotonic counter, + both call sites (T1-11 — same-second batch captures currently collide silently). +- [ ] **WAV/RIFF consolidation rider (audit §4e — SETTLED, Daniel 2026-07-28):** one pure + `wav_codec` owner (chunk walker + layout + build + patch), absorbing `ingest.cpp`'s pure + WAV/PCM build (T4-10 — the ingest shell drops to ~500 and the WAV build gains a test target). - [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds; CTest green; no realtime-tick branch-shape change. ## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` -**Goal:** Split the two unrelated command-id families in one TU (981 LOC) into +**Goal:** Split the two unrelated command-id families in one TU (1016 LOC at the Q-W0 census — +T4-03: the planned seams still land sub-600, no reshape) into `design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own `promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to @@ -442,12 +601,16 @@ contract — a reorg must not touch a shipped command id). no-undo/no-ext-state; CTest green. ## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`) -**Goal:** Split `persist.cpp` (766 LOC, 5 responsibilities) into `session` (lifecycle+poll, -`BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON serialization bridge + -GUID minting + folder relocation), and **`prune_fs`** (prune scanning + `deleteOrphanFile` via -`SHFileOperationW`). The split **concentrates** the byte-deleting authority into one obvious -module — it must never spread it. CONTEXT.md §Phase Q (persist split seams; deletion-authority -isolation). See `docs/product/code-organization.md` §2.1, §7. +**Goal:** Split `persist.cpp` (852 LOC at the Q-W0 census — T4-04: seams unchanged; the +pS-usage growth landed exactly where this wave isolates it; 5 responsibilities) into `session` +(lifecycle+poll, `BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON +serialization bridge + GUID minting + folder relocation), and **`prune_fs`** (prune scanning + +`deleteOrphanFile` via `SHFileOperationW`). The split **concentrates** the byte-deleting +authority into one obvious module — it must never spread it. **Q-W0 rider (T2-04, SETTLED +2026-07-28):** generalize the `GetProjExtState` grow-loop retry policy into `bridge_marshal`'s +pure decode home (or its `core/` successor) and rewire all three hand-rolled copies — +`usage_scan`'s prune-safety-adjacent copy included. CONTEXT.md §Phase Q (persist split seams; +deletion-authority isolation). See `docs/product/code-organization.md` §2.1, §7. **Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip, folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved* @@ -459,6 +622,9 @@ independently landable. (serialization bridge + GUID minting + folder relocation). - [ ] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the one file-deletion module; nothing else may delete bytes. +- [ ] Dedupe the `GetProjExtState` grow-loop ×3 (T2-04): one retry policy generalized from + `bridge_marshal`; rewire `usage_scan`'s prune-safety-adjacent copy with `sample_usage_tests` + green. - [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one module; relative-paths-only holds; CTest green. @@ -466,7 +632,9 @@ independently landable. **Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers -(`capture.h`/`persist.h`) not already resolved by their TU splits (I). CONTEXT.md §Phase Q (OCP +(`capture.h`/`persist.h`) not already resolved by their TU splits (I). (Q-W0: no reshape — +T4-02 notes the ~385-line registration residue left in `app/main.cpp` after Q-W3 shrinks +further under the table.) CONTEXT.md §Phase Q (OCP registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6). **Verify:** CTest green at every commit. Every action still registers, appears in the Actions list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged @@ -488,27 +656,42 @@ GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 abandoned) — tree qu ("when Phase S and L3 are finished" — L1/L2/L3/L4–L7 all landed — GATE SATISFIED) │ ▼ -Q-W0 (pre-restructure functional + DSP quality audit — findings report + triage) - │ ── SUB-GATE: triage complete + Daniel signed off on every disposition ── - ▼ (fix-now findings remediated/assigned; downstream Q-W1..W6 reshaped as needed) -Q-W1 (safe opener: core/json extract + directory/namespace layout on clean modules) - ├─► Q-W2 (split bank_panel) ──► Q-W4 (split actions + dedupe bank verbs vs panel_bank_ops) - ├─► Q-W3 (split main.cpp; hoist orchestration) ──► Q-W6 (OCP registration-table + I splits) - └─► Q-W5 (split persist; isolate prune_fs) [best after Q-W4] +Q-W0 (audit + triage + report — COMPLETE; all 59 dispositions signed off 2026-07-28) + │ ── SUB-GATE: satisfied once the six approved fix-now remediations land ── + ▼ (T1-01 T1-03 T1-09 T2-01a T3-01 T3-03 — in flight on pq-w0-fixes) +Q-W1 (safe opener: core/json ×5 + wire codec + rect unification + relocation incl. ~20 VST + │ pure libs under core/instrument/{engine,map,ui} + riders) + ├─► Q-W2 (split bank_panel — 8 seams) ──► Q-W4 (split actions + dedupe vs panel_bank_ops) + ├─► Q-W2v (NEW: VST god-modules — editor 8 TUs / processor 3 TUs / component_state_io; + │ sampler_core TU whole — documented exception) [parallel with Q-W2: zero overlap] + ├─► Q-W3 (split main — 4 hoists incl. capture_batch; + wav_codec, ICaptureBackend deletion, + │ stamp dedupe, T1-11, capture_realtime_finalize) ──► Q-W6 (OCP registration-table) + └─► Q-W5 (split persist; + ext-state-loop dedupe) [best after Q-W4] ``` -Q-W0 is the **entry point** — the functional/DSP audit runs FIRST and gates Q-W1 (no structural -point begins until its triage closes and Daniel signs off). W1 is then the safe, high-leverage -structural opener (all later waves assume the layout it establishes). The four god-module splits -(W2–W5) are risk-ordered and mostly parallel-safe; W4 depends on W2's `panel_bank_ops`, W6 depends -on W3's isolated registration code. Big-bang is rejected — every wave is independently landable and -CTest-green. +Q-W0 has run and is signed off (2026-07-28); its sub-gate closes when the six fix-now +remediations land. W1 is the safe, high-leverage structural opener (all later waves assume the +layout — including the T4-18 `instrument/` placement — it establishes). The god-module splits +(W2, W2v, W3, W5) are risk-ordered and mostly parallel-safe; **Q-W2v runs parallel with Q-W2** +(different artifact, zero file overlap — audit §4f SETTLED); W4 depends on W2's +`panel_bank_ops`, W6 depends on W3's isolated registration code. Big-bang is rejected — every +wave is independently landable and CTest-green. ## Phase Q — must-verify-before-build - **Q-W0 closed before any structural point** — the functional/DSP audit's findings report exists, every finding is triaged (fix-now vs. document-and-defer, each with rationale), fix-now findings are remediated or assigned to the wave that opens their file, and **Daniel has signed off on every disposition.** Q-W1 does not begin otherwise. (CONTEXT.md §Phase Q Q-W0; naming/DSP smell - categories §2c of `docs/product/code-organization.md`.) + categories §2c of `docs/product/code-organization.md`.) **Status 2026-07-28: triage + sign-off + COMPLETE (all 59 dispositions); the sub-gate closes when the six fix-now remediations land + (`pq-w0-fixes`).** +- **~600-line ceiling on every split wave** — every TU a split wave ships lands under ~600 LOC, + with `sampler_core.cpp` the single documented exception (T4-14/T4-27). Seams are the method, + the ceiling is the bar; arbitrary bisection to hit the number is rejected (Q-5 settlement, + 2026-07-28). +- **No dispatch-stack blowouts anywhere** — heuristic (3) generalizes the hot-path guardrail + beyond the three named paths: prefer static polymorphism where types are compile-time-known; + templates only where earned for compile-time dedup (T4-20 yes; T4-21's rect NO-template + ruling). - **Hot-path call/inline shape** — before landing each split, confirm no virtual dispatch and no header→TU indirection was added on `peaks` envelope compute, audition/preview, or the realtime tick. `computeEnvelope` stays a free function on `const std::vector&`; From 678274c19ba01aa705eefd6db342394c9d46072c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 18:59:46 -0400 Subject: [PATCH 09/40] =?UTF-8?q?docs(TODO-1.0):=20fold=20second=20answer?= =?UTF-8?q?=20round=20=E2=80=94=20items=201-2=20fully=20settled,=20item=20?= =?UTF-8?q?3=20down=20to=20point=20bounds=20and=20popup=20gesture=20conver?= =?UTF-8?q?gence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 136 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 101 insertions(+), 35 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 96e28f8..d5ca8ec 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -15,6 +15,11 @@ ask; settled answers are folded into **Behavior** (marked *settled by follow-up*), and only the genuinely unresolved remainder stays under **Open questions**. +A **second follow-up round** (2026-07-28, same day) settled nearly all of the +remainder. Each item carries that round's verbatim as a third provenance block; +answers are folded the same way (marked *settled by second follow-up*). Items 1 +and 2 now have no open questions; item 3 retains two narrow ones. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -46,6 +51,15 @@ in sequence or together, but 1 and 2 are prerequisites for 3's full surface. > 1.0 the linear neutral" CLAUDE, y=x^1.0 is linear!! of course! yes dragging > on the segment (add a round knot midsegment) adjusts the curve. +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> default overlay none +> what about the curves? + +("what about the curves?" is Daniel turning the pre-existing-instances +question back — the answer recorded below is derived from this doc's own +acceptance criterion, not a Daniel quote.) + **Intent.** Grow the envelope-overlay editor from an amp-only fixture into the shared graphical surface for every envelope in the instrument, and give every envelope shapeable (non-linear) segments — while fixing the blue-on-green @@ -61,6 +75,16 @@ contrast failure. displayed envelope. The switch is exclusive: **one overlay-active envelope at a time, or none** — no-envelope-shown is a valid state, not an error. *(Settled by follow-up.)* +- **Default overlay: none.** The editor opens with **no envelope selected** — + the none-state is the default, replacing today's always-on Amp AHDSR. + *(Settled by second follow-up.)* +- **Pre-existing instances load at exponent 1.0.** Saved instances from before + this change load **every curve at exponent 1.0** — the linear neutral — so + their audible envelope behavior is unchanged. *(Derived, not a Daniel quote: + 1.0 is the settled linear neutral, and the acceptance criterion "a project + saved before this change reopens with unchanged audible envelope behavior" + admits no other default. Daniel prompted the question; this is the only + answer consistent with what he has already settled.)* - **Segment curve values on all envelopes.** Amp AHDSR, Pitch AD, and Filter AHDSR all gain an editable curve value per *sloped* segment. The curve is an exponential function; the per-segment parameter is the exponent scalar, @@ -86,14 +110,8 @@ contrast failure. **Open questions.** -- **Default overlay selection on open.** Exclusivity and the none-state are - settled; what is the default when the editor opens — the Amp AHDSR (today's - behavior) or none? -- **Pre-existing instances.** Should saved instances from before this change - load with every curve at the linear equivalent (exponent 1.0) so their sound - is unchanged? (The neutral point itself is settled; this is only the - load-behavior half. The product requirement is "old instances sound - identical.") +- None remaining — both prior questions (default overlay selection, + pre-existing-instance curve loading) closed by the second follow-up round. **Acceptance criteria.** @@ -101,6 +119,8 @@ contrast failure. envelope in the overlay, editable there, and the overlay tracks the switch immediately. At most one envelope is overlay-active; with none active, the overlay draws no envelope. +- The editor opens with no envelope overlay-active (default is none, not the + Amp AHDSR). - Every curvable-segment knob shows the inner dial (inner arc, tertiary-purple hover accent, needle, numeric label); sweeping it through 0.1 → 10 visibly reshapes the overlay segment and audibly reshapes the envelope on played @@ -111,7 +131,7 @@ contrast failure. - Overlay envelope segments render in tertiary purple and are clearly legible against the primary green waveform. - A project saved before this change reopens with unchanged audible envelope - behavior. + behavior: every curve loads at exponent 1.0. --- @@ -133,6 +153,13 @@ contrast failure. > again, scaled around root 2 at the center. mod amt targets cutoff, -100% - > +100% to cover full range from either end. per-voice. label it "Filter" +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> oh, zone. +> yes, filter is off by default +> velocity and keytracking with the rest, not deffered. Same idea as the amp +> velocity transfer curve and pitch key tracking + **Intent.** Add the instrument's first filter stage — resonant high-pass and low-pass — as a new fixed point in the per-voice signal path, with its own AHDSR envelope, and make the knob-deck row read in signal-flow order. @@ -159,6 +186,19 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. the full range from either end. - **Per-voice.** The filter processes **per voice** — each sounding voice runs its own filter with its own envelope state. *(Settled by follow-up.)* +- **Per-zone storage.** The filter's parameters are **stored per-zone**, + alongside the other playback parameters — Sample/Zone panel parity applies, + same as the existing per-zone controls (VOICE and MASTER remain the + per-instance exceptions). *(Settled by second follow-up.)* +- **Off by default.** The filter **defaults to off** — pre-existing saved + instances and freshly loaded captures sound unchanged until the user engages + it. *(Settled by second follow-up.)* +- **Velocity and key-tracking modulation — in this item, not deferred.** The + filter gains **velocity** and **key-tracking** modulation now, alongside the + mod-amt/envelope path: the same idea as the amp's velocity transfer curve + and the pitch key-tracking, respectively, applied to the filter. The + follow-up establishes the parallel, not new ranges or control layout — those + follow the cited precedents. *(Settled by second follow-up.)* - **Label.** The user-facing deck-group label is **"Filter"**. "MM preamp" is working shorthand for the DSP lineage, not UI text. *(Settled by follow-up.)* @@ -167,16 +207,8 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. **Open questions.** -- **Other mod sources.** Mod amt targeting cutoff is settled; whether any - other mod sources (velocity, key-tracking) are in scope now or deferred is - unstated. -- **Parameter storage: per-zone or per-instance.** Per-voice *processing* is - settled, but it is compatible with either storage side. Existing playback - parameters live per-zone with Sample/Zone panel parity; VOICE and MASTER are - per-instance exceptions. Which side do the filter *parameters* fall on? -- **Neutral default.** Should the filter default to a bypass/neutral state so - pre-existing saved instances (and freshly loaded captures) sound unchanged - until the user engages it? +- None remaining — all three prior questions (other mod sources, parameter + storage side, neutral default) closed by the second follow-up round. **Acceptance criteria.** @@ -196,9 +228,16 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. - Two simultaneously sounding voices at different envelope phases are filtered independently (per-voice processing is audible, not a shared instance-wide filter). +- Filter parameters follow Sample/Zone panel parity: they appear and edit on + both surfaces, and each zone carries its own filter settings (two zones with + different filter settings audibly differ). +- Velocity and key-tracking modulation of the filter ship with this item and + are audible — velocity following the amp-velocity-transfer-curve pattern, + key-tracking following the pitch-key-tracking pattern. - The deck row reads pitch → filter → amp left-to-right. -- A project saved before this change reopens sounding identical (pending the - neutral-default confirmation above). +- The filter is off by default: a project saved before this change reopens + sounding identical, and a freshly loaded capture sounds unchanged until the + filter is engaged. --- @@ -231,6 +270,13 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. > the length. all the soft points between any hard points will be > smooth/monotone. +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> normalize the spline eg length to the full width, it should represent the +> time axis of the actual sample visually 1:1 +> left click add, alt-click delete, cntrl-click toggles point hard or soft. +> spline active -> stage knobs disabled. + **Intent.** Offer a free-drawn alternative to every staged envelope: the user switches any EG from Staged to Spline mode and draws the contour directly, with the monotonic-spline machinery already proven by the velocity curve — enhanced @@ -248,6 +294,21 @@ so sharp corners are possible, not everything smoothed. Spline EG is active**; the spline **always covers the full sample length** — a pure time function over the sample, i.e. the Trigger/one-shot playback model. *(Settled by follow-up.)* +- **Time axis: normalized, visually 1:1.** The spline contour is **normalized + to the full sample length**, and the overlay represents **the time axis of + the actual sample visually 1:1** — the contour's full width maps directly + onto the displayed sample. Consequently a different-length capture rescales + the stored contour to its own length. *(Settled by second follow-up.)* +- **Point-editing grammar.** **Left-click adds a point; alt-click deletes a + point; control-click toggles a point hard/smooth.** Alt-click-delete + supersedes the earlier speculation that the velocity-curve popup's + right-click-delete grammar would be reused. *(Settled by second follow-up; + control-click hard/smooth confirms the original ask.)* +- **Staged controls disabled while Spline is active.** While a Spline EG is + active, that envelope's **staged segment knobs are disabled** — inert, not + merely inaudible — including their item-1 inner curve dials (the dial is + part of the knob). The dormant staged state is edited only by switching back + to Staged mode. *(Settled by second follow-up.)* - **Not globally monotone.** Spline contours **don't have to rise and fall and are not globally monotone**; the monotone guarantee is per-segment — **all soft points between any hard points are smooth/monotone** (no overshoot @@ -277,26 +338,24 @@ so sharp corners are possible, not everything smoothed. **Open questions.** -- **Time-axis storage.** Full-sample coverage is settled ("spline always - covers the full sample length"); what remains is only whether the stored - contour is normalized to the sample length (so it rescales when a - different-length capture loads) or anchored some other way. -- **Point-editing grammar.** Point add/delete gestures and any point-count - bound are unstated. The velocity-curve popup already established right-click - node delete — reuse of that grammar seems natural but is Daniel's call. -- **Staged controls while Spline is active.** Sound-wise the staged EG is - inactive in Spline mode (settled by dual-state). Still open at the UI level: - do the staged segment knobs/inner dials stay editable (editing the dormant - staged state) or go inert/greyed until the user switches back? +- **Point-count bounds.** The add/delete/toggle gestures are settled, but any + minimum/maximum point count is unstated (a floor of two endpoints seems + implied by full-length coverage; a ceiling, if any, is Daniel's call). +- **Velocity-curve popup gesture convergence.** The Spline-EG grammar is + alt-click delete, but the existing velocity-curve popup shipped with + right-click node delete. Since the enhanced spline serves both, does the + popup migrate to the alt-click grammar for consistency, keep right-click, or + accept both? **Acceptance criteria.** - Each of the pitch, filter, and amp EGs offers a Staged/Spline mode switch; in Spline mode the overlay (via the item-1 radio switch) shows and edits the drawn contour, and played notes audibly follow it. -- Control-click toggles any point hard/smooth; points are smooth by default; a - hard point renders a visible sharp angle with no smoothing on either - adjacent segment, and the discontinuous slope is audible where the +- Left-click on the contour adds a point at that position; alt-click on a + point deletes it; control-click toggles it hard/smooth. Points are smooth by + default; a hard point renders a visible sharp angle with no smoothing on + either adjacent segment, and the discontinuous slope is audible where the modulation target makes it so (e.g. a pitch EG corner). - A contour of several segments joined at hard points plays back over the full sample length exactly as drawn — including contours that rise and fall @@ -305,6 +364,13 @@ so sharp corners are possible, not everything smoothed. - A freshly created Spline EG shows the smooth y = 1 − x default contour. - While a Spline EG is active, Gate mode is not selectable; the spline plays as a pure time function over the full sample length. +- The overlay contour spans the full displayed sample width, 1:1 with the + sample's time axis; loading a different-length capture rescales the contour + to the new sample length (normalized storage), with the drawn shape + preserved proportionally. +- While a Spline EG is active, that envelope's staged segment knobs and their + inner curve dials render disabled and reject edits; switching back to Staged + re-enables them with values exactly as left. - Staged↔Spline round-trip preserves both states: switch to Spline, draw, switch back — the staged values are exactly as left; switch forward again — the spline contour is exactly as drawn. Both survive save/reload. From 35f02cece2ee835a09f18a36d5222f352a23f89b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 19:04:36 -0400 Subject: [PATCH 10/40] =?UTF-8?q?docs(TODO-1.0):=20fold=20third=20answer?= =?UTF-8?q?=20round=20=E2=80=94=20spline=20point=20ceiling=20128,=20right-?= =?UTF-8?q?click=20delete;=20doc=20fully=20settled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 60 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index d5ca8ec..863b2b7 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -17,8 +17,12 @@ questions**. A **second follow-up round** (2026-07-28, same day) settled nearly all of the remainder. Each item carries that round's verbatim as a third provenance block; -answers are folded the same way (marked *settled by second follow-up*). Items 1 -and 2 now have no open questions; item 3 retains two narrow ones. +answers are folded the same way (marked *settled by second follow-up*). + +A **third follow-up round** (2026-07-28, same day) settled item 3's last two +questions (point-count ceiling; delete gesture — reversing the second round's +alt-click answer back to right-click). **All three items now have no open +questions; the document is fully settled at the product level.** Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land @@ -277,6 +281,14 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. > left click add, alt-click delete, cntrl-click toggles point hard or soft. > spline active -> stage knobs disabled. +**Daniel's third follow-up (verbatim, 2026-07-28).** + +> oh, maybe 64? is that way too much? I don't want to limit from long rhythmic +> phrases, which require a high resolution to be interesting +> +> oh, do right click delete instead for the spline +> 128 then + **Intent.** Offer a free-drawn alternative to every staged envelope: the user switches any EG from Staged to Spline mode and draws the contour directly, with the monotonic-spline machinery already proven by the velocity curve — enhanced @@ -299,11 +311,22 @@ so sharp corners are possible, not everything smoothed. the actual sample visually 1:1** — the contour's full width maps directly onto the displayed sample. Consequently a different-length capture rescales the stored contour to its own length. *(Settled by second follow-up.)* -- **Point-editing grammar.** **Left-click adds a point; alt-click deletes a - point; control-click toggles a point hard/smooth.** Alt-click-delete - supersedes the earlier speculation that the velocity-curve popup's - right-click-delete grammar would be reused. *(Settled by second follow-up; - control-click hard/smooth confirms the original ask.)* +- **Point-editing grammar.** **Left-click adds a point; right-click deletes a + point; control-click toggles a point hard/smooth.** *(Settled by third + follow-up; control-click hard/smooth confirms the original ask.)* Note for + readers of the prior revision: the second follow-up answered alt-click + delete; the third follow-up **supersedes that** with right-click delete — + which matches the velocity-curve popup's already-shipped right-click node + delete, giving one point-editing grammar across both spline consumers. +- **Point-count ceiling: 128.** A spline contour holds at most **128 points** + (floor: the two endpoints implied by full-length coverage). Daniel floated + 64 and raised it to 128 explicitly so the cap does not limit **long rhythmic + phrases, which require high resolution to be interesting** — at roughly two + points per articulation event, 64 points is about two bars of 16ths and 128 + about four. The ceiling is a musical bound, not a performance one (segment + lookup is logarithmic; on-screen the editor's 8 px minimum node separation + is the practical density limit anyway). **An engineer tempted to lower this + number should read that motivation first.** *(Settled by third follow-up.)* - **Staged controls disabled while Spline is active.** While a Spline EG is active, that envelope's **staged segment knobs are disabled** — inert, not merely inaudible — including their item-1 inner curve dials (the dial is @@ -338,25 +361,26 @@ so sharp corners are possible, not everything smoothed. **Open questions.** -- **Point-count bounds.** The add/delete/toggle gestures are settled, but any - minimum/maximum point count is unstated (a floor of two endpoints seems - implied by full-length coverage; a ceiling, if any, is Daniel's call). -- **Velocity-curve popup gesture convergence.** The Spline-EG grammar is - alt-click delete, but the existing velocity-curve popup shipped with - right-click node delete. Since the enhanced spline serves both, does the - popup migrate to the alt-click grammar for consistency, keep right-click, or - accept both? +- None remaining — both prior questions closed by the third follow-up round. + Point-count bounds: ceiling 128, floor two endpoints. Gesture convergence: + resolved by the delete-gesture reversal itself — the Spline EG's right-click + delete now matches the velocity-curve popup's shipped right-click node + delete, so **no migration is needed on either side**; one grammar serves + both consumers. **Acceptance criteria.** - Each of the pitch, filter, and amp EGs offers a Staged/Spline mode switch; in Spline mode the overlay (via the item-1 radio switch) shows and edits the drawn contour, and played notes audibly follow it. -- Left-click on the contour adds a point at that position; alt-click on a +- Left-click on the contour adds a point at that position; right-click on a point deletes it; control-click toggles it hard/smooth. Points are smooth by default; a hard point renders a visible sharp angle with no smoothing on either adjacent segment, and the discontinuous slope is audible where the modulation target makes it so (e.g. a pitch EG corner). +- A contour accepts points up to the **128-point ceiling**; attempting to add + beyond it is refused without disturbing the existing contour. The two + endpoints cannot be deleted (full-length coverage always holds). - A contour of several segments joined at hard points plays back over the full sample length exactly as drawn — including contours that rise and fall freely (no globally-monotone restriction), with no overshoot between any @@ -375,4 +399,6 @@ so sharp corners are possible, not everything smoothed. switch back — the staged values are exactly as left; switch forward again — the spline contour is exactly as drawn. Both survive save/reload. - The velocity→amp transfer-curve editor supports the same control-click - hard/smooth toggle with identical rendering behavior. + hard/smooth toggle with identical rendering behavior, and its existing + right-click node delete matches the Spline EG's delete gesture unchanged — + one point-editing grammar across both consumers. From 15d293b42f56680fb5a1a88f03963d79d1a81799 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 18:44:52 -0400 Subject: [PATCH 11/40] =?UTF-8?q?fix(q-w0):=20six=20audit=20fix-nows=20?= =?UTF-8?q?=E2=80=94=20linked-lag=20stereo=20SOLA,=20playable-span=20prime?= =?UTF-8?q?=20bound,=20declick=20dead-state,=20provenance=20cursor=20harde?= =?UTF-8?q?ning,=20rate-derived=20gain=20ramp=20+=20fade=20ceiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/provenance.cpp | 45 +++++++++++++++----- src/vst/pitch_shift.cpp | 43 ++++++++++++++++++- src/vst/pitch_shift.h | 56 +++++++++++++++++++++---- src/vst/reasampler_editor.cpp | 33 +++++++++++---- src/vst/reasampler_editor.h | 5 +++ src/vst/reasampler_processor.cpp | 36 ++++++++++------ src/vst/reasampler_processor.h | 9 +++- src/vst/sampler_core.cpp | 72 ++++++++++++++++++++++---------- src/vst/sampler_core.h | 7 ++-- tests/test_pitch_shift.cpp | 66 +++++++++++++++++++++++++++++ tests/test_provenance.cpp | 56 +++++++++++++++++++++++++ tests/test_sampler_core.cpp | 67 +++++++++++++++++++++++++++++ 12 files changed, 428 insertions(+), 67 deletions(-) diff --git a/src/provenance.cpp b/src/provenance.cpp index 6054924..5423af7 100644 --- a/src/provenance.cpp +++ b/src/provenance.cpp @@ -2,6 +2,7 @@ #include #include +#include // provenance implementation — pure, self-contained (no third-party lib, mirror of // bank_model's hand-rolled encoding discipline). @@ -60,22 +61,33 @@ public: bool ok() const { return ok_; } bool atEnd() const { return pos_ >= s_.size(); } - // Reads one length-prefixed field into `out`. Fails on a missing ':', - // non-numeric length, or a length that runs past the end. + // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or + // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past + // the end. Hardened form backported from the assignment_request / sample_usage + // siblings (Q-W0 T2-01a): the digit count is capped at 20 (the decimal width of + // SIZE_MAX on a 64-bit host) so a crafted 200-digit length cannot accumulate past + // SIZE_MAX via repeated multiply, and the bounds check is subtraction-first so a + // huge `len` cannot wrap `start + len` past the end test. bool field(std::string& out) { if (!ok_) return false; - std::size_t colon = s_.find(':', pos_); + const std::size_t colon = s_.find(':', pos_); if (colon == std::string::npos) return fail(); - // Parse the length digits [pos_, colon). - std::size_t len = 0; if (colon == pos_) return fail(); // empty length token + // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. + if (colon - pos_ > 20u) return fail(); + std::size_t len = 0; for (std::size_t i = pos_; i < colon; ++i) { - char c = s_[i]; + const char c = s_[i]; if (c < '0' || c > '9') return fail(); - len = len * 10 + static_cast(c - '0'); + const std::size_t digit = static_cast(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; } const std::size_t start = colon + 1; - if (start + len > s_.size()) return fail(); + // Subtraction-first form: start + len cannot wrap on a huge len. + if (start > s_.size() || len > s_.size() - start) return fail(); out.assign(s_, start, len); pos_ = start + len; return true; @@ -87,14 +99,20 @@ public: return toInt(f, out); } + // A length-prefixed unsigned decimal (the GUID count). Hardened (Q-W0 T2-01a, the + // sample_usage fieldCount pattern): fails on empty, non-digit, a digit run past 20 + // (SIZE_MAX's decimal width), or an accumulate that would overflow SIZE_MAX. bool fieldSizeT(std::size_t& out) { std::string f; if (!field(f)) return false; - if (f.empty()) return fail(); + if (f.empty() || f.size() > 20u) return fail(); std::size_t v = 0; - for (char c : f) { + for (const char c : f) { if (c < '0' || c > '9') return fail(); - v = v * 10 + static_cast(c - '0'); + const std::size_t digit = static_cast(c - '0'); + if (v > (std::numeric_limits::max() - digit) / 10u) + return fail(); + v = v * 10u + digit; } out = v; return true; @@ -196,6 +214,11 @@ std::optional parseFingerprint(const std::string& fingerprint) { std::size_t guidCount = 0; if (!c.fieldSizeT(guidCount)) return std::nullopt; + // Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least + // 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the + // reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge) + // into std::length_error / bad_alloc through the shell. + if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt; r.trackGuids.reserve(guidCount); for (std::size_t i = 0; i < guidCount; ++i) { std::string g; diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp index 2fd34fc..59aa73d 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/vst/pitch_shift.cpp @@ -48,6 +48,7 @@ void PitchShifter::configure(std::int64_t windowFrames) { filled_ = 0; ratio_ = 1.0; tailFrozen_ = false; + lastSplice_ = SpliceEvent{}; return; } // 2x-window ring: one window of splice-jump span plus search + fade headroom on each side. @@ -97,6 +98,7 @@ void PitchShifter::reset() { filled_ = 0; ratio_ = 1.0; tailFrozen_ = false; + lastSplice_ = SpliceEvent{}; } void PitchShifter::freezeTail() { @@ -146,6 +148,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) { fadeLen_ = 0; filled_ = count; tailFrozen_ = false; // a fresh note-on always starts with a live writer + lastSplice_ = SpliceEvent{}; // ratio_ deliberately untouched: the voice sets it per frame around the prime. } @@ -163,6 +166,7 @@ void PitchShifter::warm() { fadeLen_ = 0; filled_ = window_; tailFrozen_ = false; + lastSplice_ = SpliceEvent{}; } void PitchShifter::setShiftRatio(double ratio) { @@ -311,11 +315,42 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { } fading_ = true; fadePos_ = 0; + // Record the decision for a linked follower channel (T1-01): the follower applies this + // verbatim so both channels share one lag and one splice schedule. + lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_}; } -AudioSample PitchShifter::process(AudioSample in) { +void PitchShifter::applySplice(const SpliceEvent& ev) { + // Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no + // correlation search of our own. The master's jump was clamped against ITS filled_/delay, + // which match ours by the lockstep contract (identical configure/prime/ratio history); + // the fade length likewise derives only from shared geometry + ratio. + posB_ = posA_; + double p = posA_ - static_cast(ev.jump) + static_cast(ev.lag) + ev.frac; + const double len = static_cast(ringLen_); + while (p < 0.0) p += len; + while (p >= len) p -= len; + posA_ = p; + fadeLen_ = std::max(1, ev.fadeLen); + fading_ = true; + fadePos_ = 0; + lastSplice_ = ev; // observable mirror (tests assert follower == master per frame) +} + +AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); } + +AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) { + return processImpl(in, &master); +} + +AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) { if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) + // Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer; + // 5 plain fields, negligible on the RT path). + const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{}; + lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices + // 1. Write the incoming sample at the write head (source rate). One more slot of the // ring now holds valid history (capped at the ring length once it has wrapped). // TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write @@ -335,6 +370,12 @@ AudioSample PitchShifter::process(AudioSample in) { const double gNew = 0.5 * (1.0 - std::cos(kPi * t)); out = gNew * out + (1.0 - gNew) * readTap(posB_); if (++fadePos_ >= fadeLen_) fading_ = false; + } else if (linked != nullptr) { + // 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the + // master channel did this frame. Lockstep state means our own trigger would have + // fired on the same frame; applying the master's decision keeps the two rings + // sample-aligned (one shared lag, one shared schedule). + if (linkedEv.fired) applySplice(linkedEv); } else { // 3. Splice scheduling: relocate when the active tap's delay leaves the safe band. // Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down- diff --git a/src/vst/pitch_shift.h b/src/vst/pitch_shift.h index 5fdbf1d..aed90d6 100644 --- a/src/vst/pitch_shift.h +++ b/src/vst/pitch_shift.h @@ -70,9 +70,25 @@ namespace reasampler { +// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG +// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation +// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller +// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the +// follower applies exactly this decision instead of running its own search. Both channels +// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel +// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice: +// stereo image wander at the splice cadence plus comb coloration on any mono sum. +struct SpliceEvent { + bool fired = false; // a splice was scheduled on this frame + std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed) + std::int64_t lag = 0; // correlation best integer lag + double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5] + std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen +}; + // A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel; -// a stereo voice owns two — the algorithm is per-sample and channel-count agnostic, matching -// the S7 "one read head, per-channel value" idiom of the core. +// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice +// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned. // // The default-constructed shifter is INERT: with no configure() it passes input through // unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is @@ -91,10 +107,13 @@ public: // the tap on src[0] (delay == count, mid safe band at count == window()). The caller then // feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO // structural latency at every ratio, and splices always have `count` frames of real - // history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]; pass - // the full window (pad the tail with silence yourself if the source is shorter — trailing - // silence IS the true stream there). RT-safe: bounded copy into the pre-sized ring, no - // allocation. No-op when unconfigured. The current shift ratio is left untouched. + // history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]. + // When the PLAYABLE source is shorter than one window, prime only the real span and call + // freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real + // short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring + // are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material. + // RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured. + // The current shift ratio is left untouched. void prime(const AudioSample* src, std::int64_t count); // prime()-with-silence: zero the ring, park the tap one window behind the writer, and @@ -118,6 +137,20 @@ public: // active tap leaves its safe delay band, a correlation-aligned splice is scheduled. AudioSample process(AudioSample in); + // FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process() + // except the splice decision is NOT computed here — when `master.fired` is true this + // frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is + // considered. The caller must process the master channel FIRST each frame and pass its + // lastSplice() here, with both shifters configured/primed/ratio'd identically — their + // ring state then advances in lockstep, so the follower's own trigger would have fired + // on the same frame anyway; skipping its search only removes the second correlation + // burst (strictly cheaper, never costlier). RT-safe: same guarantees as process(). + AudioSample processLinked(AudioSample in, const SpliceEvent& master); + + // The splice decision made by the most recent process()/processLinked() call (fired == + // false when that frame spliced nothing). Feed to a follower channel's processLinked(). + const SpliceEvent& lastSplice() const { return lastSplice_; } + // TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame // remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore // their input and write nothing, but read, splice, and crossfade exactly as before over @@ -150,8 +183,15 @@ private: double readTap(double pos) const; // fractional ring read, linear interp // Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled // span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind - // the writer (the caller just computed it for the trigger test). + // the writer (the caller just computed it for the trigger test). Records the decision in + // lastSplice_ for a linked follower channel. void splice(std::int64_t nominalJump, double delay); + // Apply a master channel's already-computed splice decision verbatim (no search) — + // the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_. + 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); std::vector ring_; // delay line, length `ringLen_` == 2 * window_ std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through @@ -176,6 +216,8 @@ private: // its up-jump to this so no splice lands in unwritten // silence — the GA2 onset-gap fix. double ratio_ = 1.0; // current shift ratio (>0) + SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01): + // cleared at the top of every frame, set on a splice bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap // recycles the ring's frozen real tail, splices still // aligned. With the writer parked, a tap drains toward it diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index a46c222..744f4c1 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -389,10 +389,13 @@ namespace { // engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span // [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build // resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) -// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a -// wall-clock second). Build-time residual — one place to retune; not persisted. +// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is +// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — +// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the +// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate +// ruling). Build-time residual — one place to retune; not persisted. constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) -constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames) +constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) @@ -401,10 +404,11 @@ double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over - // the frames ceiling. Two domains, kept explicit so neither leaks a rate. + // the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate. + const double fadeMax = fadeMaxFrames(); const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; - const auto framesToNorm = [](std::int64_t f) { - return clamp01(static_cast(f) / kFadeMaxFrames); + const auto framesToNorm = [fadeMax](std::int64_t f) { + return clamp01(static_cast(f) / fadeMax); }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; @@ -429,9 +433,10 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, int segment) const { + const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; - const auto normToFrames = [](double v) { - return static_cast(clamp01(v) * kFadeMaxFrames + 0.5); + const auto normToFrames = [fadeMax](double v) { + return static_cast(clamp01(v) * fadeMax + 0.5); }; switch (static_cast(id)) { case ParamControl::kPlayMode: @@ -467,6 +472,18 @@ double ReaSamplerEditor::liveSampleRate() const { return processor_ ? processor_->sampleRate() : 0.0; } +double ReaSamplerEditor::fadeMaxFrames() const { + // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) + // resolved against the live rate — the SAME time base the envelope overlay already uses + // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), + // and the rate captures are made at (the capture path renders at the project rate). The + // 44.1 kHz fallback covers the pre-setupProcessing window (rate still 0) and reproduces + // the legacy 88200-frame ceiling there. Storage stays SOURCE FRAMES — this resolves the + // UI ceiling only. + const double rate = liveSampleRate(); + return kFadeMaxSeconds * (rate > 0.0 ? rate : 44100.0); +} + double ReaSamplerEditor::previewVelocity01() const { if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; return static_cast(processor_->previewVelocity()) / 127.0; diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 238a2f8..91e0082 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -318,6 +318,11 @@ private: // into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place. void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const; + // The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s + // wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in + // rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames. + double fadeMaxFrames() const; + // --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ---------- // // envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 027527c..eb8f2d4 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -43,12 +43,14 @@ namespace { // FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. constexpr std::size_t kPreserveVoiceCap = 8; -// FB1 post-mixer gain ramp rate (per sample). gainCurrent_ converges to masterGain_ at this -// linear step; it ramps from 0 to unity (or vice versa) in ~20 ms at 48 kHz. The early-out -// (|current - target| < threshold) snaps to the target and avoids the ramp loop on idle blocks. -// kGainRampSnap is the threshold below which we snap to the target (avoids long sub-LSB creep). -constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms -constexpr float kGainRampSnap = kGainRampRate * 0.5f; +// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a +// linear per-sample step derived from this at setupProcessing (gainRampStep_ = +// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing +// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as +// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is +// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ +// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. +constexpr double kGainRampSeconds = 0.020; // pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy // source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the @@ -203,6 +205,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { sampleRate_ = setup.sampleRate; maxBlockSize_ = setup.maxSamplesPerBlock; + // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at + // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host + // guarantees setupProcessing never overlaps process), read on the audio thread only. + if (sampleRate_ > 0.0) { + gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); + } return SingleComponentEffect::setupProcessing(setup); } @@ -1072,13 +1080,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // actual output. Branch-free inner loop; early-out when already at target. RT-safe. { const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; const float diff = gTarget - gainCurrent_; - if (diff < -kGainRampSnap || diff > kGainRampSnap) { + if (diff < -gSnap || diff > gSnap) { // Ramp toward target: step per sample, then apply the per-sample gain. for (int32 i = 0; i < frames; ++i) { const float d = gTarget - gainCurrent_; - if (d > kGainRampRate) gainCurrent_ += kGainRampRate; - else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; else gainCurrent_ = gTarget; ch0[i] *= gainCurrent_; ch1[i] *= gainCurrent_; @@ -1115,12 +1125,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. { const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; const float diff = gTarget - gainCurrent_; - if (diff < -kGainRampSnap || diff > kGainRampSnap) { + if (diff < -gSnap || diff > gSnap) { for (int32 i = 0; i < frames; ++i) { const float d = gTarget - gainCurrent_; - if (d > kGainRampRate) gainCurrent_ += kGainRampRate; - else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; else gainCurrent_ = gTarget; ch0[i] *= gainCurrent_; } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 703884c..73f0e85 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -454,13 +454,18 @@ private: // FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free // atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward - // it per-sample each block (linear interpolation, ~20 ms at 48 kHz / 256-frame block) + // it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate) // so sudden knob moves produce no zipper noise and the true-zero bottom causes no click. std::atomic masterGain_{1.0f}; // The audio-thread running gain value: tracks masterGain_ across blocks, stepping at - // most kGainRampRate per sample toward the target. Starts at unity (pre-FB1 default). + // most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default). // Written and read exclusively on the audio thread — no atomics needed. float gainCurrent_ = 1.0f; + // T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock) + // against the live host rate in setupProcessing — never a baked-in rate. The default is + // the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in + // setupProcessing (host-serialized against process), read on the audio thread. + float gainRampStep_ = 1.0f / 960.0f; // --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) --------- // The editor's preview-trigger button posts a note-on/off request from the UI thread; process() diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 2b8e24f..c0920cd 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -297,8 +297,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // Any in-flight ramp is superseded: pending re-derives from the reference, which already // includes the running declick's contribution via lastOut (it tracks post-declick output). declickActive_ = false; - declickL_ = 0.0; - declickR_ = 0.0; + declickWeight_ = 0.0; active_ = true; releasing_ = false; @@ -378,25 +377,49 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote const SampleLoop& loop = sample.loop; const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0; const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured(); + // Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at + // feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and + // freezes the writer there (GA3) — but the prime used to pull a FULL window bounded + // only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an + // up-shifted tap could play it, transposed, before the voice freed), and a + // shorter-than-window sample got zero padding declared as valid history (splices + // landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window + // material). So bound the prime by the same playable span and, when that span is + // shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3 + // machinery then recycles the real short tail, its designed behavior. The sustain- + // loop path is unbounded by construction (the wrap keeps q inside the loop forever). + const std::int64_t primeBound = + (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) + ? playEnd_ : frameCount; + const std::int64_t primeCount = + loopWrap ? w : std::min(w, primeBound - start); // Both channels walk identical SOURCE positions (the walk depends only on loop geometry, // not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1. std::int64_t p = start; for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) { const std::vector& pcmCh = ch == 0 ? sample.frames : sample.framesR; std::int64_t q = start; - for (std::int64_t i = 0; i < w; ++i) { + for (std::int64_t i = 0; i < primeCount; ++i) { if (loopWrap) { while (q >= loop.end) q -= loopLen; } + // q < frameCount holds by construction on the non-loop path (primeCount is + // bounded); the guard stays as a belt for the loop-wrap walk. primeBuf_[static_cast(i)] = (q < frameCount) ? pcmCh[static_cast(q)] : 0.0f; ++q; } - (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), w); + (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount); if (ch == 0) p = q; // capture the end position once from channel 0's walk } - // Per-frame feed continues at `p`, exactly one window ahead of readPos_. + // Per-frame feed continues at `p` (== the feed bound when the prime exhausted the + // playable span — advanceFrame's own exhaustion test then holds from frame 0). feedPos_ = p; + if (!loopWrap && primeCount < w) { + // Sub-window playable span: the source is ALREADY exhausted at prime time. + shiftL_.freezeTail(); + if (stereoSample) shiftR_.freezeTail(); + } } ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine. } @@ -457,8 +480,7 @@ void Voice::seedDeclick(double newOutL, double newOutR) { // gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction. (void)newOutL; (void)newOutR; // consumed only for the floor guard below declickPending_ = false; - declickL_ = 1.0; - declickR_ = 1.0; + declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state) // The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp // on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here. // Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend. @@ -512,11 +534,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { if (declickActive_) { // Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref. // The weight decays by kDeclickDecay each frame, floor-checked on the weight itself. - const double l = declickL_ * declickRefL_; - const double r = declickL_ * declickRefR_; // same weight for both channels - declickL_ *= kDeclickDecay; - declickR_ *= kDeclickDecay; - if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) { + const double l = declickWeight_ * declickRefL_; + const double r = declickWeight_ * declickRefR_; // same weight for both channels + declickWeight_ *= kDeclickDecay; + if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) { declickActive_ = false; active_ = false; } @@ -578,15 +599,21 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { outL = shiftedL * gain; if (stereo) { if (haveR && shiftR_.configured()) { - // Genuine stereo: an independent shifter transposes channel 1. Each shifter is - // process()'d EXACTLY ONCE per output frame (never twice — that would advance its - // heads twice and corrupt the OLA state). 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 into this one. + // Genuine stereo (Q-W0 T1-01, 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). 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 ? pcmR[static_cast(feedPos_)] : 0.0f; shiftR_.setShiftRatio(shift); - outRlocal = static_cast(shiftR_.process(feedR)) * gain; + outRlocal = + static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())) * + gain; } 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 shiftL_.process again @@ -636,13 +663,12 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { // Inactive (the common case) costs one branch; the blend itself costs one extra subtract. if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL); if (declickActive_) { - const double addL = declickL_ * (declickRefL_ - outL); - const double addR = declickL_ * (declickRefR_ - (stereo ? outRlocal : outL)); + const double addL = declickWeight_ * (declickRefL_ - outL); + const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL)); outL += addL; if (stereo) outRlocal += addR; - declickL_ *= kDeclickDecay; - declickR_ *= kDeclickDecay; // kept in sync (mirrors L — both channels share one weight) - if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) { + declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together + if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) { declickActive_ = false; } } diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 90dc92a..c2c68bc 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -582,7 +582,9 @@ private: // recent rendered output (post-gain, incl. any running declick). A takeover/steal start() // records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_; // the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND: - // outₙ = outₙ*(1−w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by + // outₙ = outₙ*(1−w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared + // by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy) + // starts at 1.0 and decays by // kDeclickDecay each frame. This is algebraically `outₙ + w*(ref − outₙ)`, so the // boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by // max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising. @@ -595,8 +597,7 @@ private: bool declickActive_ = false; double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target) double declickRefR_ = 0.0; - double declickL_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame - double declickR_ = 0.0; + double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame double lastOutL_ = 0.0; double lastOutR_ = 0.0; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index b913993..255a5aa 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -23,6 +23,9 @@ // 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a // clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the // bit) — the GA2 immediate-onset claim. +// 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors +// the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on +// decorrelated stereo content where an independent per-channel search provably diverges. #include "../src/vst/pitch_shift.h" @@ -491,6 +494,68 @@ static void testFreezeTailContinuousTone() { } } +// --- 8. Stereo linked lag (Q-W0 T1-01): a follower channel driven via processLinked() +// applies EXACTLY the master's splice decision — same firing frame, same jump, same +// lag, same sub-sample frac, same fade length — so a stereo pair shares ONE splice +// schedule (no inter-channel offset re-drawn per splice: the pre-fix image-wander / +// mono-sum-combing mechanism). The divergence witness: an INDEPENDENT shifter fed the +// follower's content picks a different lag on the same schedule, proving the mirror +// assertion is not vacuous (the two channels' contents genuinely disagree on the best +// alignment). --- +static void testStereoLinkedLagSharedSchedule() { + const std::int64_t w = 2205; // the product window + const std::size_t n = 40000; // ~17 splice cycles at ratio 2 + // Decorrelated "stereo" content: two different non-integer-period tones, so each + // channel's own correlation optimum lands on a different lag. + const double fL = 1.0 / 196.37; + const double fR = 1.0 / 123.13; + std::vector srcL(n + static_cast(w)); + std::vector srcR(n + static_cast(w)); + for (std::size_t i = 0; i < srcL.size(); ++i) { + srcL[i] = static_cast(std::sin(2.0 * kPi * fL * static_cast(i))); + srcR[i] = static_cast(std::sin(2.0 * kPi * fR * static_cast(i))); + } + PitchShifter master, follower, independent; + master.configure(w); + follower.configure(w); + independent.configure(w); + master.prime(srcL.data(), w); + follower.prime(srcR.data(), w); // linked: R content, master's decisions + independent.prime(srcR.data(), w); // control: R content, OWN search (pre-fix behavior) + master.setShiftRatio(2.0); + follower.setShiftRatio(2.0); + independent.setShiftRatio(2.0); + + int spliceCount = 0; + bool followerDiverged = false; + bool independentDiverged = false; + for (std::size_t i = 0; i < n; ++i) { + const std::size_t si = i + static_cast(w); + (void)master.process(srcL[si]); + const SpliceEvent& em = master.lastSplice(); + const AudioSample oR = follower.processLinked(srcR[si], em); + CHECK(std::isfinite(oR)); + // The follower mirrors the master's decision EXACTLY, every frame (fired == false + // frames included — a follower must never splice on its own). + const SpliceEvent& ef = follower.lastSplice(); + if (ef.fired != em.fired || ef.jump != em.jump || ef.lag != em.lag || + ef.frac != em.frac || ef.fadeLen != em.fadeLen) { + followerDiverged = true; + } + if (em.fired) ++spliceCount; + // The control: same content as the follower, own search. Its decision differing + // from the master's proves the mirror assertion above is load-bearing. + (void)independent.process(srcR[si]); + const SpliceEvent& ei = independent.lastSplice(); + if (ei.fired != em.fired || ei.lag != em.lag || ei.frac != em.frac) { + independentDiverged = true; + } + } + CHECK(spliceCount >= 3); // the run actually exercised several splices + CHECK(!followerDiverged); // linked lag: one decision, one schedule, both channels + CHECK(independentDiverged); // non-tautology witness: unlinked channels DO disagree +} + int main() { testDurationInvariance(); testUnityRoughlyReproduces(); @@ -499,6 +564,7 @@ int main() { testRepitchSpectralPurityAndOnset(); testUnityBitExactAndLatency(); testFreezeTailContinuousTone(); + testStereoLinkedLagSharedSchedule(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); diff --git a/tests/test_provenance.cpp b/tests/test_provenance.cpp index 3671f93..a2b19f3 100644 --- a/tests/test_provenance.cpp +++ b/tests/test_provenance.cpp @@ -7,6 +7,8 @@ // track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe). // * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable. // * parse of malformed / wrong-version / truncated input -> nullopt (graceful). +// * hardened wire cursor (Q-W0 T2-01a): hostile digit-run lengths, wrap-magnitude +// lengths, and huge GUID counts -> nullopt with no overflow and no over-allocation. // * parent-detection decision: positive, negative, ambiguous, empty, and the // edge where a source file is not in the bank (missing-from-bank). // @@ -190,6 +192,58 @@ static void testMalformedFingerprint() { "1:0" "0:").has_value()); } +// --- Q-W0 T2-01a: hardened wire cursor (backported from assignment_request / +// sample_usage) — corrupt or crafted persisted fingerprints must fail the parse +// cleanly (nullopt), never wrap an integer, never throw, never over-allocate. ---- + +// Mirrors buildFingerprint's field order with benign values, except the GUID-count +// field carries caller-supplied raw text — the attack surface under test. +static void putF(std::string& out, const std::string& f) { + out += std::to_string(f.size()); + out += ':'; + out += f; +} +static std::string forgedFingerprint(const std::string& guidCountText) { + std::string out = "rsprov1"; + putF(out, "0"); // scope = Item + putF(out, "0"); // sourceMode + putF(out, "0"); // startSeconds + putF(out, "1"); // endSeconds + putF(out, "0"); // tailMode + putF(out, "0"); // tailMs + putF(out, "48000"); // sampleRate + putF(out, "2"); // channelCount + putF(out, guidCountText); // GUID count (no GUID fields follow) + putF(out, ""); // fxChainIdentity (empty) + return out; +} + +static void testHardenedCursorRejectsHostileLengths() { + // A 200-digit length run: pre-hardening the accumulate wrapped std::size_t silently + // (the digit cap + overflow guard now reject it outright). + CHECK(!parseFingerprint("rsprov1" + std::string(200, '9') + ":x").has_value()); + // A SIZE_MAX-magnitude length: the additive bounds check `start + len > size` could + // itself wrap and pass; the subtraction-first form rejects. + CHECK(!parseFingerprint("rsprov118446744073709551615:x").has_value()); + // One past SIZE_MAX: the per-digit overflow guard fires during the accumulate. + CHECK(!parseFingerprint("rsprov118446744073709551616:x").has_value()); +} + +static void testHugeGuidCountRejectedBeforeReserve() { + // A GUID count astronomically larger than the wire could hold must return nullopt + // WITHOUT reaching trackGuids.reserve(count) — pre-fix this drove reserve(10^16) + // into std::length_error / bad_alloc thrown through the shell. + CHECK(!parseFingerprint(forgedFingerprint("9999999999999999")).has_value()); + // A count merely past the wire-size sanity bound (each GUID field needs >= 2 wire + // bytes) is provably bogus and rejected before the field loop. + CHECK(!parseFingerprint(forgedFingerprint("1000")).has_value()); + // A digit run past 20 fails the count parser's cap. + CHECK(!parseFingerprint(forgedFingerprint(std::string(25, '9'))).has_value()); + // Sanity (non-vacuous forgery): the honest zero-count version of the same forged + // shape parses fine — the rejections above are the count's doing, not the shape's. + CHECK(parseFingerprint(forgedFingerprint("0")).has_value()); +} + // --- recorded-recipe model round-trips through the Sample JSON ---------------- // The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's // BankIndex JSON already round-trips. Prove a real recipe survives that path intact. @@ -296,6 +350,8 @@ int main() { testFxChainIdentityInjectionProof(); testCombineChainIdentities(); testMalformedFingerprint(); + testHardenedCursorRejectsHostileLengths(); + testHugeGuidCountRejectedBeforeReserve(); testRecipeThroughSampleJson(); testDetectParentPositive(); testDetectParentMultipleSameParent(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index e494d30..279953c 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -2474,6 +2474,68 @@ static void testPreserveTriggerTailGapFree() { } } +// --- Q-W0 T1-03: the Preserve prime is bounded by the PLAYABLE span. A Trigger zone whose +// play length is shorter than the OLA window must never carry source PAST the user's +// chosen stop into the ring — pre-fix, the prime pulled a full window bounded only by +// frameCount, and an up-shifted tap PLAYED the cut content (transposed) before the voice +// freed. The sample poisons everything past playEnd with amplitude 8: if any of it +// reaches the output, the peak bound fails. --- +static void testPreservePrimeStopsAtTriggerPlayEnd() { + const std::size_t frames = 8000; + const std::size_t w = 2048; // OLA window >> playable span + const std::size_t playLen = 500; // playEnd = round(8000 * 0.0625) = 500 + SampleData s; + s.frames.resize(frames); + const double f0 = 1.0 / 50.0; // 10 cycles inside the playable span + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = i < playLen + ? static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))) + : 8.0f; // POISON: cut content past the play end + } + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Preserve; + s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into + // the deepest primed history the ring holds + std::vector out; + eng.render(out, playLen + 64); // through the voice's own end (readPos >= playEnd) + double peak = 0.0; + for (const AudioSample v : out) { + const double a = std::fabs(static_cast(v)); + if (a > peak) peak = a; + } + CHECK(peak < 1.5); // the 8.0 poison never sounds: nothing past playEnd entered the ring + CHECK(peak > 0.4); // ...and the real span genuinely played (the bound is not vacuous) +} + +// --- Q-W0 T1-03 (companion): a whole sample SHORTER than the window (Gate, no loop) must not +// get zero padding declared as valid ring history — pre-fix, the prime zero-filled the +// window remainder with filled_ = window, so splices/tap travel landed in silence: +// hundreds-of-frames dead runs inside a sub-window one-shot (the pre-GA2 burst/gap +// artifact re-entering for short material). Post-fix the prime stops at the sample end +// and freezes the tail immediately, so the ring recycles ONLY real content. --- +static void testPreserveSubWindowSampleNoZeroPadInRing() { + const std::size_t frames = 1200; // sample < one window + const std::size_t w = 2048; + const double f0 = 1.0 / 96.0; // period 96: zero crossings dwell ~2 frames + SampleData s = tailSine(frames, f0, 60); + s.play.pitchEngine = PitchEngine::Preserve; + s.play.adsr = flatAdsr(); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring) + std::vector out; + eng.render(out, frames); // voice runs to its natural end (no loop) + // Pre-fix: the tap crossed the declared-valid zero pad repeatedly — quiet runs of 150+ + // frames. Post-fix every relocation stays inside the real filled span; only sine zero + // crossings dip below the threshold. + CHECK(worstQuietRun(out, 0, frames, 0.05) < 30); + CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -2584,6 +2646,11 @@ int main() { testPreserveTailReleaseContinuous(); testPreserveTriggerTailGapFree(); + // Q-W0 T1-03 — the prime is bounded by the playable span (Trigger playEnd / sample end), + // with an immediate tail freeze on sub-window spans. + testPreservePrimeStopsAtTriggerPlayEnd(); + testPreserveSubWindowSampleNoZeroPadInRing(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; From 16b2a1b8ca134ccc28755a83c841eab06af25d28 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 19:27:12 -0400 Subject: [PATCH 12/40] Q-W0 code-review remediation: pitch_shift follower self-heal, T3-03 rate bail, doc/comment riders --- docs/product/code-quality-audit.md | 8 ++++++++ src/provenance.cpp | 3 +++ src/vst/pitch_shift.cpp | 28 +++++++++++++++++++++++--- src/vst/reasampler_editor.cpp | 22 +++++++++++++------- tests/test_pitch_shift.cpp | 32 ++++++++++++++++++++++++++---- 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/docs/product/code-quality-audit.md b/docs/product/code-quality-audit.md index df59c85..196df62 100644 --- a/docs/product/code-quality-audit.md +++ b/docs/product/code-quality-audit.md @@ -315,6 +315,14 @@ the clean bills) are deliberately absent. fallback): document-and-defer with the comment amended to name the 44.1 k assumption, if zero UI-feel change is preferred. (Folding either into Q-W2v instead is *not* recommended — it would put behavior changes inside a mechanical-split wave; §2.5(8).) + + **Recorded deviation (Q-W0 remediation, code review):** T3-03 as implemented resolves + `fadeMaxFrames()` against `liveSampleRate()` (the host/project rate), not the per-file rate + this section's text literally suggests ("the loaded source's rate"). Reviewer verified this + is the more correct choice: no resample path exists anywhere in `src/`, the engine advances + one source frame per host frame, and this matches the time base `paintEnvelopeOverlay` + already uses for the same fades (`totalSeconds = frames / liveSampleRate()`). No further + action — recorded here so the audit text and the shipped behavior don't read as diverged. - **(e) WAV/RIFF consolidation moment — the one true track disagreement (T2-08 vs T4-23/T4-10).** T2 prefers the `core/wav` homing moment (the relocation wave); T4 prefers "the wave that opens `ingest.cpp`" — which does not exist, and T4-10 points back circularly. *Recommend:* record diff --git a/src/provenance.cpp b/src/provenance.cpp index 5423af7..89cd816 100644 --- a/src/provenance.cpp +++ b/src/provenance.cpp @@ -140,6 +140,9 @@ public: private: bool fail() { ok_ = false; return false; } + // TODO(Q-W1): strtol does not check errno/range here, so an out-of-range field narrows + // silently to LONG_MAX (then truncates into `int`) instead of failing parse. Flagged for + // the Q-W1 wire-codec collapse rather than fixed in place. static bool toInt(const std::string& f, int& out) { const char* b = f.c_str(); char* end = nullptr; diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp index 59aa73d..dd73d16 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/vst/pitch_shift.cpp @@ -201,8 +201,9 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { // search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's // read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search, // +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead), - // so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample of - // conservative margin, never out-of-range. In steady state (filled_ == ringLen_) this is + // so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER + // than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so + // this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is // > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to // what real history exists (still many source periods with a full-window prime). The floor of // 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant. @@ -375,7 +376,28 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) // master channel did this frame. Lockstep state means our own trigger would have // fired on the same frame; applying the master's decision keeps the two rings // sample-aligned (one shared lag, one shared schedule). - if (linkedEv.fired) applySplice(linkedEv); + if (linkedEv.fired) { + applySplice(linkedEv); + } else { + // Self-healing fallback (review rider): the master not firing normally means this + // channel's own trigger wouldn't fire either (lockstep). But if the processor ever + // renders a mono block mid-note, this follower channel is skipped for that block + // while the master keeps advancing — its writePos_/filled_ falls behind and, with + // only the `if (linkedEv.fired)` path above, could never resync. So check this + // follower's OWN tap distance against the safe band and splice via its own search + // when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() — + // no allocation, no new RT cost. In the normal (non-mono-block) case this branch + // never triggers: the master's trigger fires first and this whole `if` is false. + double d = static_cast(writePos_) - posA_; + const double len = static_cast(ringLen_); + while (d < 0.0) d += len; + while (d >= len) d -= len; + if (d <= static_cast(dLow_)) { + splice(+window_, d); + } else if (d >= static_cast(dHigh_)) { + splice(-window_, d); + } + } } else { // 3. Splice scheduling: relocate when the active tap's delay leaves the safe band. // Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down- diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 744f4c1..13f8a6c 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -405,10 +405,14 @@ double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over // the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate. + // A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 + // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old + // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. const double fadeMax = fadeMaxFrames(); const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto framesToNorm = [fadeMax](std::int64_t f) { - return clamp01(static_cast(f) / fadeMax); + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; @@ -435,7 +439,9 @@ void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, int segment) const { const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; - const auto normToFrames = [fadeMax](double v) { + const auto normToFrames = [fadeMax](double v) -> std::int64_t { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + if (fadeMax <= 0.0) return 0; return static_cast(clamp01(v) * fadeMax + 0.5); }; switch (static_cast(id)) { @@ -476,12 +482,14 @@ double ReaSamplerEditor::fadeMaxFrames() const { // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) // resolved against the live rate — the SAME time base the envelope overlay already uses // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), - // and the rate captures are made at (the capture path renders at the project rate). The - // 44.1 kHz fallback covers the pre-setupProcessing window (rate still 0) and reproduces - // the legacy 88200-frame ceiling there. Storage stays SOURCE FRAMES — this resolves the - // UI ceiling only. + // and the rate captures are made at (the capture path renders at the project rate). + // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the + // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — + // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert + // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. const double rate = liveSampleRate(); - return kFadeMaxSeconds * (rate > 0.0 ? rate : 44100.0); + if (rate <= 0.0) return 0.0; + return kFadeMaxSeconds * rate; } double ReaSamplerEditor::previewVelocity01() const { diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index 255a5aa..a92c2fe 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -501,7 +501,19 @@ static void testFreezeTailContinuousTone() { // mono-sum-combing mechanism). The divergence witness: an INDEPENDENT shifter fed the // follower's content picks a different lag on the same schedule, proving the mirror // assertion is not vacuous (the two channels' contents genuinely disagree on the best -// alignment). --- +// alignment). A third shifter (`mirror`), primed with the SAME content as the master +// and driven via processLinked() with the master's own decisions, must reproduce the +// master's output BIT-IDENTICALLY every frame — this is the review-rider strengthening: +// the `ef == em` mirror check above only proves lastSplice_ was copied verbatim (which +// applySplice() always does), not that applySplice() actually reproduces splice()'s +// effect on posA_/fadeLen_/audio output; a same-content bit-identical check catches a +// real divergence there (e.g. an asymmetry between applySplice()'s unconditional +// `max(1, ev.fadeLen)` and splice()'s own fadeLen_ assignment). This driven-every-frame +// setup keeps both master and follower in lockstep the whole run (posA_/writePos_ stay +// identical since jumps are geometric, not content-dependent), so it exercises +// applySplice() on every splice — never the Q-W0 remediation self-healing fallback +// (own-search splice on a stale follower), which only fires when a follower has been +// skipped a block relative to the master (mono-render-block starvation). --- static void testStereoLinkedLagSharedSchedule() { const std::int64_t w = 2205; // the product window const std::size_t n = 40000; // ~17 splice cycles at ratio 2 @@ -515,28 +527,34 @@ static void testStereoLinkedLagSharedSchedule() { srcL[i] = static_cast(std::sin(2.0 * kPi * fL * static_cast(i))); srcR[i] = static_cast(std::sin(2.0 * kPi * fR * static_cast(i))); } - PitchShifter master, follower, independent; + PitchShifter master, follower, independent, mirror; master.configure(w); follower.configure(w); independent.configure(w); + mirror.configure(w); master.prime(srcL.data(), w); follower.prime(srcR.data(), w); // linked: R content, master's decisions independent.prime(srcR.data(), w); // control: R content, OWN search (pre-fix behavior) + mirror.prime(srcL.data(), w); // SAME content as master: bit-identical witness master.setShiftRatio(2.0); follower.setShiftRatio(2.0); independent.setShiftRatio(2.0); + mirror.setShiftRatio(2.0); int spliceCount = 0; bool followerDiverged = false; bool independentDiverged = false; + bool mirrorDiverged = false; for (std::size_t i = 0; i < n; ++i) { const std::size_t si = i + static_cast(w); - (void)master.process(srcL[si]); + const AudioSample oM = master.process(srcL[si]); const SpliceEvent& em = master.lastSplice(); const AudioSample oR = follower.processLinked(srcR[si], em); CHECK(std::isfinite(oR)); // The follower mirrors the master's decision EXACTLY, every frame (fired == false - // frames included — a follower must never splice on its own). + // frames included). In this driven-every-frame lockstep run the follower never falls + // behind, so it never reaches the Q-W0 self-healing fallback — every splice here goes + // through applySplice(), same as the mirror check below. const SpliceEvent& ef = follower.lastSplice(); if (ef.fired != em.fired || ef.jump != em.jump || ef.lag != em.lag || ef.frac != em.frac || ef.fadeLen != em.fadeLen) { @@ -550,10 +568,16 @@ static void testStereoLinkedLagSharedSchedule() { if (ei.fired != em.fired || ei.lag != em.lag || ei.frac != em.frac) { independentDiverged = true; } + // The bit-identical witness: same content as the master, master's decisions applied + // via applySplice() instead of computed via splice() — the two code paths must produce + // the exact same sample stream. + const AudioSample oMirror = mirror.processLinked(srcL[si], em); + if (oMirror != oM) mirrorDiverged = true; } CHECK(spliceCount >= 3); // the run actually exercised several splices CHECK(!followerDiverged); // linked lag: one decision, one schedule, both channels CHECK(independentDiverged); // non-tautology witness: unlinked channels DO disagree + CHECK(!mirrorDiverged); // applySplice() reproduces splice() bit-identically } int main() { From 113d268553cf2fcfe3ee150c637b5585dbe78344 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 19:31:58 -0400 Subject: [PATCH 13/40] =?UTF-8?q?docs(TODO-1.0):=20append=20second=20batch?= =?UTF-8?q?=20(items=204-13)=20=E2=80=94=20three=20bugs,=20loop-point=20re?= =?UTF-8?q?gression=20plus=20Gate=20loop-sustain=20spec,=20and=20seven=20U?= =?UTF-8?q?I=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 396 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 392 insertions(+), 4 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 863b2b7..d34ce8a 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -1,7 +1,9 @@ # TODO-1.0 -Post-1.0 enhancement queue for the ReaSampler 9000 instrument. Three items, in -Daniel's ordering (2026-07-28). Deliberately specified at the level of product +Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two +extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering +(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day). +Deliberately specified at the level of product intent, user-visible behavior, and acceptance criteria — **no implementation design, no file/module references**. These were authored while Phase Q was restructuring the tree; the implementing engineer maps each spec onto the @@ -21,12 +23,32 @@ answers are folded the same way (marked *settled by second follow-up*). A **third follow-up round** (2026-07-28, same day) settled item 3's last two questions (point-count ceiling; delete gesture — reversing the second round's -alt-click answer back to right-click). **All three items now have no open -questions; the document is fully settled at the product level.** +alt-click answer back to right-click). **All three first-batch items now have +no open questions; the first batch is fully settled at the product level.** + +A **second batch** from Daniel (2026-07-28) appends items 4–13 — a mix of bug +reports and enhancements. His message numbered them 1, 2, 3, 4, 3, 4, 5, 6, 7, +8 (two items labelled 3 and two labelled 4). His *sequence* is preserved +exactly; each item carries an unambiguous doc number continuing from 3, with +his original label recorded in the heading (e.g. *his 3, second*) so his +message maps onto the doc. Each item is marked **Bug** or **Enhancement**; +item 9 is explicitly both — a suspected regression plus a feature spec. Bugs +are recorded compactly (symptom, expected behavior, acceptance gate) with **no +root-cause analysis** — same no-code-reads constraint as the first batch. Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. +Second-batch interactions: the three bugs (4–6) are independent of the +enhancement chain and can land at any time. Item 8 edits the same overlay +surface as item 1 and is cheapest folded into or immediately after that work; +item 10's per-ring reset presupposes item 1's inner dials; item 11's filter +velocity-curve placement presupposes item 2's Filter deck; item 9's +loop-sustain is a Gate-mode (Staged) feature and composes with item 3's +Gate-unavailable-in-Spline rule. Item 13 (anti-aliasing audit) touches nearly +every surface the other items repaint — sequencing it after the layout/knob +work (1, 8, 10, 11, 12) likely avoids doing the polish twice; that is an +observation, not a decision. --- @@ -402,3 +424,369 @@ so sharp corners are possible, not everything smoothed. hard/smooth toggle with identical rendering behavior, and its existing right-click node delete matches the Spline EG's delete gesture unchanged — one point-editing grammar across both consumers. + +--- + +## 4 — Bug: end-of-sample click in Trigger mode under Preserve *(his 1)* + +**Daniel's report (verbatim, 2026-07-28).** + +> actual bug: in trigger mode, polyphonic or monophonic, in preserved pitch +> mode only, the end of the sample has an audible click. + +**Symptom.** In Trigger mode — polyphonic or monophonic alike — with the pitch +engine in **Preserve mode only**, the end of the sample produces an audible +click. The scoping is the useful part of the report and is recorded as given: +Trigger × Preserve × end-of-sample; Varispeed is not implicated. No cause +speculation here. + +**Expected behavior.** A Trigger one-shot in Preserve mode ends silently — no +click or discontinuity at the sample end, in either voice mode. + +**Acceptance gate.** A Trigger-mode note played in Preserve, in both Poly and +Mono, ends with no audible click at the sample end (verified by ear and by +inspecting the rendered output for a terminal discontinuity). Varispeed +playback is unchanged. + +--- + +## 5 — Bug: drag-out sometimes lands without audio (extension) *(his 2)* + +**Daniel's report (verbatim, 2026-07-28).** + +> other bug (extension, not VST): Dragging capture out of the pool or bank does +> sometimes doesn't actually contain the audio, and I hae to try dragging +> again. + +**Symptom.** Extension side, not the VST. Dragging a capture out of the pool +or a named bank **sometimes** produces a drop that does not actually contain +the audio; retrying the drag works. Both details are load-bearing: the failure +is intermittent, and a retry succeeds. + +**Expected behavior.** Every completed drag-out delivers the capture's audio +at the drop target — first try, every time. + +**Acceptance gate.** Because the failure is intermittent, the gate is a soak: +across a sustained session of varied drag-outs (pool and bank sources, +including the first drag after other bank activity), every drop yields a +playable file containing the audio, with no retry ever needed. + +--- + +## 6 — Bug: drop onto FX container loads instrument without the capture *(his 3, first)* + +**Daniel's report (verbatim, 2026-07-28).** + +> drop to FX container bug: the reasmpler 9000 loads, but not with the capture. +> drop to FX button works as expected with capture preloaded. + +**Symptom.** Dropping a capture onto an **FX container** loads a ReaSampler +9000 instance, but **without the capture**. Dropping the same capture onto the +**FX button** works as expected — instrument loads with the capture preloaded. + +**Expected behavior.** The FX-button path is the reference; the container path +must match it: instrument added *with* the dragged capture loaded. + +**Acceptance gate.** A capture dropped onto an FX container yields an +instrument instance with that capture loaded and immediately playable, +indistinguishable (apart from where the FX sits) from the FX-button drop. The +FX-button path remains unregressed. + +--- + +## 7 — Enhancement: stereo waveform shows both channels *(his 4, first)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> VST Waveform Visualizer: in stereo mode, both L and R channels should show +> in the waveform visual. left on top. mono mode still shows just one channel +> for unredundancy. + +**Behavior.** + +- In **stereo mode**, the waveform visual shows **both L and R channels, left + on top** (two stacked lanes). +- In **mono mode**, a single channel shows — no redundant duplicate lane. +- The display keys off the active channel mode, per Daniel's phrasing. + +**Open questions.** + +- How overlays that ride the waveform (the envelope overlay, markers, and + item 9's loop region if it lands) render across the stereo split — spanning + the full stacked height once, or drawn per lane. A layout call to make at + implementation with Daniel's eye; the product intent is only that overlays + stay legible and unambiguous in both modes. + +**Acceptance criteria.** + +- A stereo capture in stereo mode shows two stacked lanes, L above R, each a + true view of its channel's content (an asymmetric-channel capture visibly + differs between lanes). +- Mono mode shows exactly one lane. Switching modes updates the display + accordingly. + +--- + +## 8 — Enhancement: staged-envelope overlay — release anchored right, dragged from its top node *(his 3, second)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> AHDSR visual overlay looks odd, not taking up the whole range. with no +> release, the sustain portion only travels accross a small portion of the +> panel, making the thing look off center. to resolve, the release segment +> should be anchored to the right, and draggable from the top node (connecting +> to sustain segment) instead of the bottom corner, which will now be anchored. +> All staged envelope overlays should follow this policy. + +**Intent.** A layout-policy change to the staged envelope overlay so it uses +the full panel width: today, with little or no release, the sustain portion +occupies only a small stretch and the whole figure reads off-center. + +**Behavior.** + +- The **release segment anchors to the right edge** of the overlay. +- Release is dragged from its **top node** — the node joining sustain to + release — instead of the bottom corner. The bottom corner (the envelope's + end point) becomes **fixed/anchored**, not draggable. +- The policy applies to **all staged envelope overlays** — amp AHDSR today, + Pitch AD, and the filter AHDSR when item 2 lands. +- **Cross-references.** Item 1 reworks this same overlay surface (radio + switch, mid-segment curve knots, recolor) — this item is cheapest folded + into or immediately after that work. Item 3's Spline overlays are unaffected + by construction: a spline always spans the full sample width already. + +**Open questions.** + +- How the policy maps onto the two-stage Pitch AD, which has no sustain or + release: presumably its final (decay) segment's endpoint anchors right and + drags from its top node, but Daniel stated the policy in AHDSR terms — + confirm the AD reading at implementation. + +**Acceptance criteria.** + +- With release at zero or minimum, the sustain segment extends to (near) the + right edge — the overlay reads full-width, not bunched left. +- Dragging the sustain→release top node adjusts release; the bottom-right + corner is fixed and not draggable. +- Every staged envelope overlay (amp, pitch, and filter once present) follows + the same anchoring policy. + +--- + +## 9 — Bug + Enhancement: loop points — suspected regression, and the Gate-mode loop-sustain spec *(his 4, second)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> I think loop points got lost. ideally if in gate mode we have a loopable +> section with parameterized start end points and parameterized crossfade on +> reset, which will function as the sustain for indefinite playback until note +> off and release. + +**This item is both** a regression report and a feature spec, and is recorded +as both. + +**Regression half (Bug).** Daniel's observation: loop points appear to have +been lost. Whether they were genuinely removed from playback or are merely +unexposed in the current UI is a code question that **cannot be answered here** +under the no-code-reads constraint — it must be verified as the first act of +implementation, not guessed in this document. + +**Feature half (Enhancement — spec as given).** In **Gate mode**: a loopable +section with **parameterized start and end points** and a **parameterized +crossfade on loop reset**. The loop functions as the sustain — indefinite +playback cycling the loop until note-off, then release. + +- **Cross-reference item 3.** Gate mode is unavailable while a Spline EG is + active (settled), so loop-sustain is a **Staged/Gate-mode feature**; Spline + mode remains full-sample-length playback with no loop. + +**Open questions.** + +- The regression verification above (removed vs. unexposed). +- The crossfade parameter's units and range are unspecified — Daniel call, or + a proposed default surfaced at implementation review. +- Storage side: presumably per-zone alongside the other playback parameters + (Sample/Zone panel parity, with VOICE/MASTER the per-instance exceptions) — + confirm. +- Editing surface for loop start/end (waveform markers, knobs, or both) is + unspecified; the waveform display is the natural home for range markers, but + Daniel has not said. + +**Acceptance criteria.** + +- In Gate mode with a loop defined, a held note sustains indefinitely, audibly + cycling the loop section; note-off exits into the release stage. +- With a nonzero crossfade, the loop seam is smooth — no click at the loop + reset; crossfade length audibly follows its parameter. +- Loop start, end, and crossfade are user-parameterized, editable, and + persisted across save/reload. +- Whatever the regression finding, the end state is loop points exposed and + functional per this spec. + +--- + +## 10 — Enhancement: knob and label sizing, ms units, double-click reset *(his 5)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> Radial knobs and text labels are too small. The labels for time constants +> should be in ms not seconds. double clicking any radial knob resets to +> default (applies to the dual-ring radial knobs as individual sections) + +**Behavior.** + +- Radial knobs and their text labels grow — both are currently too small. No + target size was given; this is a visual-judgment change accepted by eye. +- Time-constant labels display in **ms, not seconds**. (A display-unit change; + this spec makes no claim about internal representation.) +- **Double-click on any radial knob resets it to its default value.** +- On item 1's **dual-ring knobs, each ring is its own reset target**: + double-click on the outer ring resets the time/level value; double-click on + the inner curve dial resets the exponent to 1.0 (the settled linear + neutral) — each independently, without touching the other ring. +- **Cross-reference item 1** (introduces the dual-ring knobs this refines). + +**Open questions.** + +- None beyond the sizing being judged by eye (see acceptance). + +**Acceptance criteria.** + +- Knobs and labels are legibly larger; Daniel signs off on the result by eye. +- Every time-constant label reads in ms. +- Double-click resets any radial knob to its default; on dual-ring knobs, + double-clicking the inner dial resets only the exponent (to 1.0) and + double-clicking the outer ring resets only the value. + +--- + +## 11 — Enhancement: preview glyph; velocity-curve buttons per section; bipolar pitch/filter curves *(his 6)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> Preview button inner text should go and be replaced with an appropriate +> glyph of your choosing (no dependencies just load a bitmap staticly or +> something, whatever plays nice with LICE. Amp velocity curve button moves +> to the master section. pitch velocity curve goes in the pitch section. +> filter velocity curve button goes in the filter section. filter and pitch +> velocity transfer functions default to y=0 and y range is [-1,1] (where as +> amp stays unipolar at [0,1]). + +**Behavior.** + +- **Preview glyph.** The preview button's inner text is replaced with a glyph. + Proposed (product level): a right-pointing **play triangle** — the universal + "audition" read. Daniel's constraint: **no new dependencies** — a statically + embedded bitmap or equivalent that plays nicely with the existing drawing + path is fine. +- **Velocity-curve button placement.** Amp velocity curve button → **MASTER** + section; pitch velocity curve → **PITCH** section; filter velocity curve → + **Filter** section (the deck item 2 creates). +- **Bipolar pitch/filter transfer functions.** Pitch and filter velocity + transfer functions are **bipolar: y range [−1, 1], default y = 0** — flat at + zero, meaning velocity modulation of pitch and filter is **off until the + user draws a curve**. **Amp stays unipolar at [0, 1]**, and its existing + flat default (every velocity → unity) is unchanged. +- **Cross-references.** Item 2 introduces the filter's velocity modulation and + the Filter deck — this item specifies that curve's domain, default, and + button placement. Item 3's shared spline editor serves these curves, so it + must render and edit a **bipolar y-domain** for pitch and filter alongside + the amp curve's unipolar one. + +**Open questions.** + +- Whether a user-facing pitch velocity transfer curve already exists or is + introduced by this item — unverifiable here under the no-code-reads + constraint; if absent, this item introduces it. +- The amp velocity curve's *button* moves to MASTER, but MASTER is settled as + a per-instance group while the velocity curve is a playback parameter. Does + the move imply the amp velocity curve becomes per-instance, or is it a + purely spatial relocation with storage unchanged? This also touches + Sample/Zone panel parity (which surfaces show the button). Needs a Daniel + call before implementation. + +**Acceptance criteria.** + +- The preview button shows the glyph (no text) and stays legible in all + interaction states; no new build or runtime dependency is introduced. +- The three velocity-curve buttons sit in their named sections: amp in MASTER, + pitch in PITCH, filter in Filter. +- Opening the pitch or filter curve shows a bipolar editor ([−1, 1]) defaulted + flat at y = 0; played velocities produce no pitch/filter modulation until a + curve is drawn, then audibly follow it. +- The amp curve's domain ([0, 1]) and flat-unity default are unchanged. + +--- + +## 12 — Enhancement: top toolbar cleanup; full-width piano strip; uniform keys; note-name tooltips *(his 7)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> top toolbar text font should be cleaned up to match, and the zone count +> label is not needed in the title. next to the browse and zoom buttons, we +> can move the preview and mono stereo controls, in order to allow the note +> range piano roll control to take up full width. also we need to clean up +> the piano key pattern, idk if it's pixel aliasing but it looks like some +> keys are skinnier than others. NOte value (C4, etc) should display in +> tooltip on hover over the piano notes. + +**Behavior.** + +- **Font cleanup.** One consistent font treatment across the top toolbar text. +- **Zone count label removed** from the title. +- **Preview and mono/stereo controls relocate** next to the browse and zoom + buttons, freeing the **note-range piano strip to take the full width**. +- **Uniform piano keys.** Some keys currently render skinnier than others — + Daniel suspects pixel aliasing, but the requirement stands regardless of + cause: keys of the same class render at uniform width. (No cause analysis + here.) +- **Note-name tooltips.** Hovering a piano key shows its note value (C4 etc., + DAW convention), following the instrument's existing tooltip conventions + where they apply. +- **Cross-references.** Item 11 also changes the preview button (glyph); the + two compose — the glyph button in its new toolbar position. Item 13's + anti-aliasing audit covers the key-pattern rendering if aliasing turns out + to be the cause. + +**Acceptance criteria.** + +- Top toolbar text renders in one consistent font treatment; the title carries + no zone count. +- Preview and mono/stereo sit adjacent to browse/zoom; the piano strip spans + the full editor width. +- Same-class keys are equal pixel width at any window width and DPI scale. +- Hovering any piano key shows its note name in a tooltip. + +--- + +## 13 — Enhancement (audit): antialiased rendering for high-DPI *(his 8)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> We should finally review to make sure we are rendering with some kind of +> antialiasing equivalent for high DPI high res clean rendering. It still +> looks pixely in places (radial arcs, waveform lines, env segment slopes) + +**Intent.** A review pass, not a point fix: audit the editor's drawn surfaces +and confirm they render with antialiasing (or an equivalent) suitable for +high-DPI, high-resolution displays. Daniel named the visibly pixely surfaces: +**radial arcs, waveform lines, envelope segment slopes**. + +**Behavior.** + +- Audit each class of drawn surface; where one renders visibly aliased, bring + it to the smooth standard. The outcome is observable, not procedural: the + named surfaces (and any others the audit turns up) render without visible + stair-stepping. +- **Sequencing observation** (not a decision): this touches nearly every + surface items 1, 3, 7, 8, 10, 11, and 12 repaint — running it after the + layout/knob work likely avoids doing the polish twice. + +**Acceptance criteria.** + +- Radial arcs (including item 1's inner dials), waveform lines (including + item 7's stereo lanes), and envelope segment slopes (staged and spline) + render smooth — no visible jaggies at 100% scale or on a high-DPI display. +- The audit produces a short disposition list: surfaces checked, which needed + work, which were already clean. +- Daniel signs off by eye on the named surfaces. From 587032ffa47fd85900e0fd8d931353ab88955e78 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 19:49:05 -0400 Subject: [PATCH 14/40] =?UTF-8?q?docs(TODO-1.0):=20fold=20second-batch=20a?= =?UTF-8?q?nswers=20=E2=80=94=20pitch=20AD=20becomes=20AHD,=20VELOCITY=20d?= =?UTF-8?q?eck=20supersedes=20MASTER=20placement,=20full-height=20overlays?= =?UTF-8?q?=20with=20linked=20stereo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 164 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 124 insertions(+), 40 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index d34ce8a..e33cd5a 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -36,6 +36,15 @@ item 9 is explicitly both — a suspected regression plus a feature spec. Bugs are recorded compactly (symptom, expected behavior, acceptance gate) with **no root-cause analysis** — same no-code-reads constraint as the first batch. +A **second-batch follow-up round** (2026-07-28, same day) answered items 7, 8, +and 11. Item 7 closes clean. Item 11's answer **supersedes the original ask's +button placement** — a new VELOCITY deck group replaces the per-section +placement, MASTER being reserved for later post-voice-mixer concerns. Item 8's +answer is a **spec change, not a clarification**: the pitch envelope's AD +becomes an **AHD** (a Hold stage is added), and the doc's own prose (items 1 +and 8) is updated to match — Daniel's verbatim asks stay exactly as originally +written. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -43,7 +52,8 @@ Second-batch interactions: the three bugs (4–6) are independent of the enhancement chain and can land at any time. Item 8 edits the same overlay surface as item 1 and is cheapest folded into or immediately after that work; item 10's per-ring reset presupposes item 1's inner dials; item 11's filter -velocity-curve placement presupposes item 2's Filter deck; item 9's +velocity curve presupposes item 2's filter (though its button now homes in +item 11's own VELOCITY deck, not the Filter deck); item 9's loop-sustain is a Gate-mode (Staged) feature and composes with item 3's Gate-unavailable-in-Spline rule. Item 13 (anti-aliasing audit) touches nearly every surface the other items repaint — sequencing it after the layout/knob @@ -111,13 +121,16 @@ contrast failure. saved before this change reopens with unchanged audible envelope behavior" admits no other default. Daniel prompted the question; this is the only answer consistent with what he has already settled.)* -- **Segment curve values on all envelopes.** Amp AHDSR, Pitch AD, and Filter - AHDSR all gain an editable curve value per *sloped* segment. The curve is an +- **Segment curve values on all envelopes.** Amp AHDSR, Pitch AHD (AD in the + original ask; item 8's follow-up adds the Hold stage), and Filter AHDSR all + gain an editable curve value per *sloped* segment. The curve is an exponential function; the per-segment parameter is the exponent scalar, range **0.1 to 10**. - **Which segments are sloped.** **Every stage except Hold and Sustain** — for - an AHDSR that is Attack, Decay, and Release; for the Pitch AD, both stages. - *(Settled by follow-up.)* + an AHDSR that is Attack, Decay, and Release; for the Pitch AHD (A→H→D since + item 8's spec change), Attack and Decay — its Hold, like every Hold, is flat + and carries no curve dial. *(Settled by follow-up; the pitch reading updated + for item 8's AD→AHD change.)* - **Linear neutral.** Exponent **1.0 is the linear neutral** (y = x^1.0 is linear). *(Settled by follow-up — emphatically.)* - **Curve editing in the overlay.** Dragging on a segment in the overlay @@ -502,20 +515,28 @@ FX-button path remains unregressed. > in the waveform visual. left on top. mono mode still shows just one channel > for unredundancy. +**Daniel's follow-up (verbatim, 2026-07-28).** + +> 7) One full height; stereo linked processing, one editor + **Behavior.** - In **stereo mode**, the waveform visual shows **both L and R channels, left on top** (two stacked lanes). - In **mono mode**, a single channel shows — no redundant duplicate lane. - The display keys off the active channel mode, per Daniel's phrasing. +- **Overlays draw once, at full height.** Overlays that ride the waveform + (the envelope overlay, markers, and item 9's loop region if it lands) draw + **once at full height across both stacked lanes** — not per lane. + *(Settled by follow-up.)* +- **Stereo processing is linked.** One editor, one set of controls governing + both channels — no per-channel parameter divergence, no per-channel editing + surface. *(Settled by follow-up.)* **Open questions.** -- How overlays that ride the waveform (the envelope overlay, markers, and - item 9's loop region if it lands) render across the stereo split — spanning - the full stacked height once, or drawn per lane. A layout call to make at - implementation with Daniel's eye; the product intent is only that overlays - stay legible and unambiguous in both modes. +- None remaining — the overlay-layout question closed by the second-batch + follow-up round. **Acceptance criteria.** @@ -524,10 +545,15 @@ FX-button path remains unregressed. differs between lanes). - Mono mode shows exactly one lane. Switching modes updates the display accordingly. +- In stereo mode, waveform-riding overlays (envelope, markers, loop region if + present) render once at full stacked height — no duplicated per-lane copies + — and stay legible across both lanes. +- No per-channel controls appear; every edit applies identically to both + channels (linked stereo processing, one editor). --- -## 8 — Enhancement: staged-envelope overlay — release anchored right, dragged from its top node *(his 3, second)* +## 8 — Enhancement: staged-overlay release anchoring; the Pitch AD becomes AHD *(his 3, second)* **Daniel's ask (verbatim, 2026-07-28).** @@ -538,9 +564,18 @@ FX-button path remains unregressed. > to sustain segment) instead of the bottom corner, which will now be anchored. > All staged envelope overlays should follow this policy. +**Daniel's follow-up (verbatim, 2026-07-28).** + +> 8) For the AD... make it AHD, and the combined A H and D segment lengths +> (time displacement) cannot exceed the full sample length. Hold goes from 0 +> to 100%, and the visual overlay for the envelope is then 1:1 scale with the +> waveform time. D is not release, so it is not right anchored. + **Intent.** A layout-policy change to the staged envelope overlay so it uses the full panel width: today, with little or no release, the sustain portion -occupies only a small stretch and the whole figure reads off-center. +occupies only a small stretch and the whole figure reads off-center. The +follow-up round grew this item beyond layout: it now also carries **the spec +change that turns the pitch envelope from AD into AHD**. **Behavior.** @@ -548,19 +583,43 @@ occupies only a small stretch and the whole figure reads off-center. - Release is dragged from its **top node** — the node joining sustain to release — instead of the bottom corner. The bottom corner (the envelope's end point) becomes **fixed/anchored**, not draggable. -- The policy applies to **all staged envelope overlays** — amp AHDSR today, - Pitch AD, and the filter AHDSR when item 2 lands. +- **The policy applies to the envelopes that have a release** — the amp AHDSR + today and the filter AHDSR when item 2 lands. **It does not apply to the + pitch envelope: D is not a release stage, so it is not right-anchored.** + *(Settled by follow-up — this closes the prior open question about mapping + the policy onto the pitch envelope: the answer is that the policy does not + apply there; the envelope gains a Hold stage instead.)* +- **Spec change: the pitch envelope becomes AHD.** The Pitch AD gains a + **Hold** stage: **Attack → Hold → Decay**. *(Settled by follow-up — a spec + change, not a clarification. The doc's own prose — here and item 1's + sloped-segment reading — is updated to match; Daniel's verbatim asks stay + as written.)* + - **Hold ranges 0 to 100%.** Of *what* is unstated — see Open questions. + - **A + H + D combined cannot exceed the sample length:** the three segment + lengths' total time displacement is hard-bounded by the full sample + length. + - **Because of that bound, the pitch-envelope overlay is 1:1 scale with the + waveform time axis** — the drawn envelope maps directly onto the + displayed sample's time. - **Cross-references.** Item 1 reworks this same overlay surface (radio switch, mid-segment curve knots, recolor) — this item is cheapest folded - into or immediately after that work. Item 3's Spline overlays are unaffected - by construction: a spline always spans the full sample width already. + into or immediately after that work; item 1's sloped-segment rule reads + Attack and Decay for the pitch AHD (Hold is flat, no curve dial). Item 3's + Spline overlays are unaffected by construction: a spline always spans the + full sample width already. **Open questions.** -- How the policy maps onto the two-stage Pitch AD, which has no sustain or - release: presumably its final (decay) segment's endpoint anchors right and - drags from its top node, but Daniel stated the policy in AHDSR terms — - confirm the AD reading at implementation. +- **Hold's 0–100% — percent of what?** Daniel did not say: the full sample + length, the time remaining after Attack, or something else. A narrow Daniel + call — do not assume at implementation. +- **Are the 1:1-overlay property and the A+H+D ≤ sample-length bound + pitch-specific, or intended for all staged envelopes?** Daniel stated both + in the pitch-AHD context. An AHDSR with a right-anchored release cannot be + strictly 1:1 across an indefinite sustain, so the two policies appear to + coexist (right-anchoring for envelopes with a release; the 1:1 time-bounded + overlay for the pitch AHD) rather than merge — but that is an open reading + to confirm, not a settled conclusion. **Acceptance criteria.** @@ -568,8 +627,14 @@ occupies only a small stretch and the whole figure reads off-center. right edge — the overlay reads full-width, not bunched left. - Dragging the sustain→release top node adjusts release; the bottom-right corner is fixed and not draggable. -- Every staged envelope overlay (amp, pitch, and filter once present) follows - the same anchoring policy. +- The envelopes with a release (amp, and filter once present) follow the same + anchoring policy; the pitch envelope's Decay is **not** right-anchored. +- The pitch envelope plays and displays three stages — Attack, Hold, Decay — + with Hold spanning 0–100% of its (to-be-confirmed) reference; no + combination of A, H, and D settings yields a combined time displacement + exceeding the sample length. +- The pitch-envelope overlay is 1:1 with the waveform's time axis: a stage + boundary at N seconds sits over the waveform at N seconds. --- @@ -660,7 +725,7 @@ playback cycling the loop until note-off, then release. --- -## 11 — Enhancement: preview glyph; velocity-curve buttons per section; bipolar pitch/filter curves *(his 6)* +## 11 — Enhancement: preview glyph; VELOCITY deck for velocity-curve buttons; bipolar pitch/filter curves *(his 6)* **Daniel's ask (verbatim, 2026-07-28).** @@ -672,6 +737,13 @@ playback cycling the loop until note-off, then release. > velocity transfer functions default to y=0 and y range is [-1,1] (where as > amp stays unipolar at [0,1]). +**Daniel's follow-up (verbatim, 2026-07-28).** + +> 11) actually, that is a real contention point... MASTER is for other things, +> post voice mixer (I will add to this later). The velocity popup buttons +> should all go together in a new control deck group labelled VELOCITY, to the +> left of VOICE + **Behavior.** - **Preview glyph.** The preview button's inner text is replaced with a glyph. @@ -679,38 +751,50 @@ playback cycling the loop until note-off, then release. "audition" read. Daniel's constraint: **no new dependencies** — a statically embedded bitmap or equivalent that plays nicely with the existing drawing path is fine. -- **Velocity-curve button placement.** Amp velocity curve button → **MASTER** - section; pitch velocity curve → **PITCH** section; filter velocity curve → - **Filter** section (the deck item 2 creates). +- **Velocity-curve button placement — the VELOCITY deck.** All three + velocity-curve popup buttons (amp, pitch, filter) live **together in a new + control deck group labelled "VELOCITY", placed to the left of the VOICE + group**. *(Settled by follow-up.)* Note for readers of the prior revision: + this **supersedes the original ask's per-section placement** (amp → MASTER, + pitch → PITCH, filter → Filter). Daniel confirmed the MASTER placement was + a real contention point — **MASTER is reserved for other, post-voice-mixer + concerns** (he will add to it later), so the velocity curves do not belong + there. +- **Storage stays per-zone.** The velocity transfer curves are stored + **per-zone**, with the other playback parameters — Sample/Zone panel parity + applies. *(Derived, not a Daniel quote: the storage doubt existed only + because the proposed MASTER placement implied per-instance storage; with + the buttons in their own VELOCITY group that doubt is gone, and the + project's settled convention — playback parameters are per-zone, VOICE and + MASTER the per-instance exceptions — decides it.)* - **Bipolar pitch/filter transfer functions.** Pitch and filter velocity transfer functions are **bipolar: y range [−1, 1], default y = 0** — flat at zero, meaning velocity modulation of pitch and filter is **off until the user draws a curve**. **Amp stays unipolar at [0, 1]**, and its existing flat default (every velocity → unity) is unchanged. -- **Cross-references.** Item 2 introduces the filter's velocity modulation and - the Filter deck — this item specifies that curve's domain, default, and - button placement. Item 3's shared spline editor serves these curves, so it - must render and edit a **bipolar y-domain** for pitch and filter alongside - the amp curve's unipolar one. +- **Cross-references.** Item 2 introduces the filter's velocity modulation — + this item specifies that curve's domain and default, and homes its button + in the VELOCITY deck. Item 3's shared spline editor serves these curves, so + it must render and edit a **bipolar y-domain** for pitch and filter + alongside the amp curve's unipolar one. **Open questions.** - Whether a user-facing pitch velocity transfer curve already exists or is introduced by this item — unverifiable here under the no-code-reads - constraint; if absent, this item introduces it. -- The amp velocity curve's *button* moves to MASTER, but MASTER is settled as - a per-instance group while the velocity curve is a playback parameter. Does - the move imply the amp velocity curve becomes per-instance, or is it a - purely spatial relocation with storage unchanged? This also touches - Sample/Zone panel parity (which surfaces show the button). Needs a Daniel - call before implementation. + constraint; if absent, this item introduces it. (The former second question + — amp-curve storage under the MASTER placement — dissolved with the + placement's supersession; storage is settled per-zone above.) **Acceptance criteria.** - The preview button shows the glyph (no text) and stays legible in all interaction states; no new build or runtime dependency is introduced. -- The three velocity-curve buttons sit in their named sections: amp in MASTER, - pitch in PITCH, filter in Filter. +- The three velocity-curve buttons sit together in a deck group labelled + **VELOCITY**, immediately to the left of the VOICE group; no velocity-curve + button appears in MASTER, PITCH, or Filter. +- The velocity curves follow Sample/Zone panel parity and persist per-zone + (two zones with different curves audibly differ). - Opening the pitch or filter curve shows a bipolar editor ([−1, 1]) defaulted flat at y = 0; played velocities produce no pitch/filter modulation until a curve is drawn, then audibly follow it. From b2013f205636c1fce33b272ca61efadb3bfa40c4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 20:00:26 -0400 Subject: [PATCH 15/40] =?UTF-8?q?docs(TODO-1.0):=20close=20item=208=20?= =?UTF-8?q?=E2=80=94=20Hold=20is=20a=20share=20of=20the=20post-A+D=20remai?= =?UTF-8?q?nder,=20so=20the=20length=20bound=20holds=20by=20construction;?= =?UTF-8?q?=201:1=20overlay=20scoped=20to=20sustain-less=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 78 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index e33cd5a..4c8556a 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -45,6 +45,16 @@ becomes an **AHD** (a Hold stage is added), and the doc's own prose (items 1 and 8) is updated to match — Daniel's verbatim asks stay exactly as originally written. +A **second-batch second follow-up round** (2026-07-28, same day) closed item +8's last two questions — Hold's 0–100% reference, and the scope of the +1:1-overlay/combined-bound policy. **With that, no open question anywhere in +this doc awaits a Daniel decision.** Everything still marked open is +verify-or-propose-at-implementation, not a blocker: item 9's loop-point +regression verification and its spec details (crossfade units, storage +confirmation, editing surface — each carries its own resolve-at-review path in +the item), and item 11's does-a-user-facing-pitch-velocity-curve-already-exist +check. Nothing is blocked on Daniel. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -571,6 +581,12 @@ FX-button path remains unregressed. > to 100%, and the visual overlay for the envelope is then 1:1 scale with the > waveform time. D is not release, so it is not right anchored. +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> 1) 100% of the sample length - attack+decay times +> 2) for all envelopes that DON'T have a sustain stage. The 1:1 mapping only +> makes sense for trigger, not gated envelopes + **Intent.** A layout-policy change to the staged envelope overlay so it uses the full panel width: today, with little or no release, the sustain portion occupies only a small stretch and the whole figure reads off-center. The @@ -594,13 +610,29 @@ change that turns the pitch envelope from AD into AHD**. change, not a clarification. The doc's own prose — here and item 1's sloped-segment reading — is updated to match; Daniel's verbatim asks stay as written.)* - - **Hold ranges 0 to 100%.** Of *what* is unstated — see Open questions. - - **A + H + D combined cannot exceed the sample length:** the three segment - lengths' total time displacement is hard-bounded by the full sample - length. - - **Because of that bound, the pitch-envelope overlay is 1:1 scale with the - waveform time axis** — the drawn envelope maps directly onto the - displayed sample's time. + - **Hold ranges 0 to 100% of the time remaining after Attack and Decay** — + i.e. 100% of (sample length − (attack time + decay time)). At 100%, Hold + fills all the remaining time; at 0% it takes none. *(Settled by second + follow-up.)* + - **The A + H + D ≤ sample-length bound holds by construction**, not by a + separate clamp: Hold is expressed as a fraction of what is left after + Attack and Decay, so the sum cannot overflow. Daniel's stated bound + ("the combined A H and D segment lengths cannot exceed the full sample + length") is a *property* of the Hold definition, not a constraint to + enforce on top of it. + - **The overlay is therefore 1:1 scale with the waveform time axis** — the + drawn envelope maps directly onto the displayed sample's time. +- **Scope rule — split on the sustain stage.** The 1:1-overlay property and + the combined-time bound apply to **all envelopes that do NOT have a sustain + stage**; envelopes **with** a sustain stage (the amp and filter AHDSRs) get + the right-anchored-release policy instead. Daniel's rationale: the 1:1 + mapping only makes sense for trigger, not gated envelopes — that is the + reasoning behind the stage-list rule, not a second competing rule. Today + the pitch AHD is the only sustain-less envelope, but the rule is general: + it governs any future sustain-less envelope too. The two policies + **coexist rather than merge**, split cleanly on whether the envelope has a + sustain stage — the prior revision's open reading, now confirmed. *(Settled + by second follow-up.)* - **Cross-references.** Item 1 reworks this same overlay surface (radio switch, mid-segment curve knots, recolor) — this item is cheapest folded into or immediately after that work; item 1's sloped-segment rule reads @@ -610,16 +642,11 @@ change that turns the pitch envelope from AD into AHD**. **Open questions.** -- **Hold's 0–100% — percent of what?** Daniel did not say: the full sample - length, the time remaining after Attack, or something else. A narrow Daniel - call — do not assume at implementation. -- **Are the 1:1-overlay property and the A+H+D ≤ sample-length bound - pitch-specific, or intended for all staged envelopes?** Daniel stated both - in the pitch-AHD context. An AHDSR with a right-anchored release cannot be - strictly 1:1 across an indefinite sustain, so the two policies appear to - coexist (right-anchoring for envelopes with a release; the 1:1 time-bounded - overlay for the pitch AHD) rather than merge — but that is an open reading - to confirm, not a settled conclusion. +- None remaining — both prior questions (Hold's 0–100% reference; the scope + of the 1:1/combined-bound policy) closed by the second-batch second + follow-up round. The coexist-vs-merge reading the prior revision flagged is + confirmed: the two policies coexist, split cleanly on whether the envelope + has a sustain stage. **Acceptance criteria.** @@ -627,14 +654,17 @@ change that turns the pitch envelope from AD into AHD**. right edge — the overlay reads full-width, not bunched left. - Dragging the sustain→release top node adjusts release; the bottom-right corner is fixed and not draggable. -- The envelopes with a release (amp, and filter once present) follow the same - anchoring policy; the pitch envelope's Decay is **not** right-anchored. +- The envelopes with a sustain stage (amp, and filter once present) follow + the same anchoring policy; the pitch envelope's Decay is **not** + right-anchored. - The pitch envelope plays and displays three stages — Attack, Hold, Decay — - with Hold spanning 0–100% of its (to-be-confirmed) reference; no - combination of A, H, and D settings yields a combined time displacement - exceeding the sample length. -- The pitch-envelope overlay is 1:1 with the waveform's time axis: a stage - boundary at N seconds sits over the waveform at N seconds. + with Hold spanning 0–100% of the time remaining after Attack and Decay. By + construction, no combination of A, H, and D settings yields a combined time + displacement exceeding the sample length (at Hold = 100% the three stages + exactly fill it) — no separate clamp fires, because none is needed. +- The overlay of any sustain-less envelope (today: the pitch AHD) is 1:1 with + the waveform's time axis: a stage boundary at N seconds sits over the + waveform at N seconds. --- From 32785606d4936da3879837430d5b1d7638648108 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 20:04:59 -0400 Subject: [PATCH 16/40] =?UTF-8?q?docs(TODO-1.0):=20item=2014=20=E2=80=94?= =?UTF-8?q?=20Trigger-mode=20amp=20fades=20replaced=20by=20AHD=20with=20cu?= =?UTF-8?q?rves;=20amp=20envelope=20shape=20follows=20playback=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 4c8556a..906b04c 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -2,7 +2,8 @@ Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering -(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day). +(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day); +item 14 is a third, single-item batch (2026-07-28, later again). Deliberately specified at the level of product intent, user-visible behavior, and acceptance criteria — **no implementation design, no file/module references**. These were authored while Phase Q was @@ -47,13 +48,25 @@ written. A **second-batch second follow-up round** (2026-07-28, same day) closed item 8's last two questions — Hold's 0–100% reference, and the scope of the -1:1-overlay/combined-bound policy. **With that, no open question anywhere in -this doc awaits a Daniel decision.** Everything still marked open is +1:1-overlay/combined-bound policy. **With that, no open question then in the +doc awaited a Daniel decision.** Everything still marked open in items 1–13 is verify-or-propose-at-implementation, not a blocker: item 9's loop-point regression verification and its spec details (crossfade units, storage confirmation, editing surface — each carries its own resolve-at-review path in the item), and item 11's does-a-user-facing-pitch-velocity-curve-already-exist -check. Nothing is blocked on Daniel. +check. Nothing in items 1–13 is blocked on Daniel. + +A **third batch** (2026-07-28, later again) appends item 14 — a single +consolidation enhancement: in Trigger mode the amp envelope's +fade-in-length/fade-out-length pair is replaced by item 8's AHD (with item 1's +curves), so the amp envelope's staged shape follows the playback mode — +Gate → AHDSR, Trigger → AHD. Item 8's sustain-stage scope rule then covers the +amp envelope literally, not just by analogy (a one-line cross-reference is +added there). Item 14 carries **one question awaiting a Daniel decision** — +whether item 2's filter AHDSR follows the same mode-driven shape — so the doc +is no longer decision-clean. Its other open question (stage-value state across +the Gate/Trigger switch) is propose-at-implementation-review, not +Daniel-blocking. Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land @@ -68,7 +81,10 @@ loop-sustain is a Gate-mode (Staged) feature and composes with item 3's Gate-unavailable-in-Spline rule. Item 13 (anti-aliasing audit) touches nearly every surface the other items repaint — sequencing it after the layout/knob work (1, 8, 10, 11, 12) likely avoids doing the polish twice; that is an -observation, not a decision. +observation, not a decision. Item 14 (third batch) rides directly on item 8's +AHD definition and item 1's curve treatment — cheapest folded into or +immediately after that combined work — and repaints amp-deck/overlay surface +that items 10 and 13 later polish. --- @@ -638,7 +654,9 @@ change that turns the pitch envelope from AD into AHD**. into or immediately after that work; item 1's sloped-segment rule reads Attack and Decay for the pitch AHD (Hold is flat, no curve dial). Item 3's Spline overlays are unaffected by construction: a spline always spans the - full sample width already. + full sample width already. Item 14 extends the AHD to the amp envelope's + Trigger mode, so this scope rule selects by the envelope's current shape + under the active playback mode — see item 14. **Open questions.** @@ -904,3 +922,104 @@ high-DPI, high-resolution displays. Daniel named the visibly pixely surfaces: - The audit produces a short disposition list: surfaces checked, which needed work, which were already clean. - Daniel signs off by eye on the named surfaces. + +--- + +## 14 — Enhancement: Trigger-mode amp envelope — fade-in/fade-out replaced by the AHD (with curves) + +**Daniel's ask (verbatim, 2026-07-28).** + +> the amp env trigger mode fade in length fade out changes to the AHD envelope +> (with curves of course), to reduce code for the same thing, and consolidate +> under the new design. + +**Intent.** Consolidation. In Trigger mode the amp envelope's fade-in-length / +fade-out-length pair is retired and replaced by the same three-stage +Attack → Hold → Decay envelope item 8 specifies for the pitch envelope — with +item 1's per-segment curves. Daniel's stated motive is reducing code for the +same job: one staged-envelope design covering what is currently two separate +mechanisms doing the same thing. The product consequence: **the amp envelope's +staged shape follows the playback mode** — Gate → AHDSR (sustain, +right-anchored release, per item 8); Trigger → AHD (sustain-less). + +**Behavior.** + +- **Trigger mode: fades out, AHD in.** In Trigger mode the amp envelope is an + AHD per item 8's definition: Hold spans 0–100% of the time remaining after + Attack and Decay, so A + H + D ≤ sample length holds by construction and the + overlay is 1:1 with the waveform's time axis. The fade-in-length and + fade-out-length controls go away in Trigger mode; Attack and Decay carry + those roles under the new design. +- **With curves.** The Trigger AHD's sloped segments — Attack and Decay; Hold + is flat, as everywhere — get item 1's full curve treatment: exponent 0.1–10, + inner dials, mid-segment overlay knots, tertiary-purple rendering. +- **Gate mode unchanged.** In Gate mode the amp envelope remains the AHDSR + with item 8's right-anchored release. Item 9's loop-sustain spec (a + Gate-mode feature) is untouched. +- **Item 8's scope rule now covers the amp envelope literally.** Item 8 + settled that the 1:1-overlay property and the by-construction combined-time + bound apply to envelopes **without a sustain stage**, and the + right-anchored-release policy to those **with** one — Daniel's rationale: + "the 1:1 mapping only makes sense for trigger, not gated envelopes." With + this item the amp envelope in Trigger mode *is* sustain-less, so it takes + the 1:1 time-mapped overlay automatically; in Gate mode it keeps the + right-anchored release. The rationale now describes the amp envelope + directly, not just by analogy: the rule selects by the envelope's **current + shape under the active playback mode**, not by which processor the envelope + modulates. No new rule is needed — item 8's rule already decides both cases. +- **Pre-existing instances reopen sounding identical.** The doc's standing + migration framing (items 1, 2, 11) applies: a project saved before this + change reopens with unchanged audible behavior — in particular, a Trigger + instance's prior fade-in/fade-out contour is reproduced by the loaded AHD. + The evident mapping (Attack ← fade-in, Decay ← fade-out, Hold ← the full + remainder, curve exponents at whatever value reproduces the prior fade + shape) is a verify-at-implementation detail, not a Daniel call; the gate + below states the requirement. A prior zero fade-out is Decay = 0 — the + abrupt end stays representable, so nothing the old controls could express + is lost. +- **Cross-references.** Item 8 supplies the AHD definition and the scope rule + (this item is that rule's second consumer); item 1 supplies the curves — + sequencing: cheapest folded into or immediately after that combined work. + Item 3 is unaffected in Spline mode (a spline already plays as a + full-sample-length time function — the trigger model); its "save but + inactive" dual-state precedent bears on the second open question below. + Item 4's Trigger × Preserve end-of-sample click sits in the region the + fade-out currently governs — whichever of the two lands first, item 4's + gate must be re-verified under the surviving mechanism. + +**Open questions.** + +- **Does the filter AHDSR follow the same mode-driven shape?** Item 2's + filter envelope is the same staged machinery, so Gate → AHDSR / + Trigger → AHD would extend naturally — but Daniel said this only for the + amp envelope, and it is not asserted here. **Awaits a Daniel decision** — + currently the doc's only Daniel-pending question. +- **Stage-value state across the Gate/Trigger switch — shared or per-mode?** + The Gate AHDSR and Trigger AHD share stage names (A, H, D); whether they + share *values* (one envelope whose S and R fall away in Trigger) or keep + per-mode state (item 3's "save but inactive" dual-state precedent) is + unspecified. Migration leans per-mode: an old instance carries both its + AHDSR values and its Trigger fade values, and a shared-value model cannot + preserve both modes' prior sound at once. Resolve at implementation review + with a proposal — not Daniel-blocking, but the sound-identical gate must + hold for whichever mode a saved instance plays in. + +**Acceptance criteria.** + +- In Trigger mode the amp deck shows Attack / Hold / Decay — no fade-in or + fade-out control anywhere in Trigger mode — with curve inner dials on + Attack and Decay and none on Hold; when the amp envelope is overlay-active + (item 1's radio), its overlay is 1:1 with the waveform time axis, and no + combination of A, H, D settings exceeds the sample length (Hold = 100% + exactly fills it — by construction, no clamp). +- In Gate mode the amp envelope is the unchanged AHDSR: sustain stage, + right-anchored release per item 8. +- Switching between Gate and Trigger playback switches the amp envelope + surface (deck and overlay) between AHDSR and AHD accordingly. +- A project saved before this change reopens sounding identical: a Trigger + instance's prior fade-in/fade-out contour — including a zero fade-out's + abrupt end — is audibly reproduced by the loaded AHD. +- The Trigger amp AHD behaves stage-for-stage like item 8's pitch AHD — Hold + semantics, curve treatment, and overlay mapping match. (This is the + observable proxy for the consolidation motive: one staged-envelope design, + two consumers.) From 88e7765ee558ed61c7f9618912a8cd04299f4439 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 20:09:13 -0400 Subject: [PATCH 17/40] docs(TODO-1.0): filter envelope follows the mode-driven shape; no open questions await a Daniel decision --- TODO-1.0.md | 83 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 906b04c..45410ed 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -62,12 +62,25 @@ fade-in-length/fade-out-length pair is replaced by item 8's AHD (with item 1's curves), so the amp envelope's staged shape follows the playback mode — Gate → AHDSR, Trigger → AHD. Item 8's sustain-stage scope rule then covers the amp envelope literally, not just by analogy (a one-line cross-reference is -added there). Item 14 carries **one question awaiting a Daniel decision** — -whether item 2's filter AHDSR follows the same mode-driven shape — so the doc -is no longer decision-clean. Its other open question (stage-value state across +added there). Item 14 arrived carrying one question awaiting a Daniel +decision — whether item 2's filter AHDSR follows the same mode-driven shape — +answered by the round below. Its other open question (stage-value state across the Gate/Trigger switch) is propose-at-implementation-review, not Daniel-blocking. +A **third-batch follow-up round** (2026-07-28, same day) answered item 14's +filter question: **the filter envelope follows the same mode-driven shape** — +Gate → AHDSR, Trigger → AHD. Item 8's scope rule now governs all three +envelopes uniformly (the general statement lives in item 14; item 2 carries a +cross-reference), and item 14's remaining stage-value question now covers the +filter envelope too. **With that, no open question anywhere in the doc awaits +a Daniel decision.** Everything still open across all 14 items is +verify-or-propose-at-implementation: item 9's loop-point regression +verification and its spec details, item 11's +does-a-pitch-velocity-curve-already-exist check, and item 14's +shared-vs-per-mode stage-value question. Nothing in items 1–14 is blocked on +Daniel. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -84,7 +97,8 @@ work (1, 8, 10, 11, 12) likely avoids doing the polish twice; that is an observation, not a decision. Item 14 (third batch) rides directly on item 8's AHD definition and item 1's curve treatment — cheapest folded into or immediately after that combined work — and repaints amp-deck/overlay surface -that items 10 and 13 later polish. +that items 10 and 13 later polish; its filter half (the filter envelope +following the same mode-driven shape) necessarily lands with or after item 2. --- @@ -249,6 +263,9 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. at the center** of the control. - **Mod amt:** **bipolar, −100% to +100%**, targeting **cutoff** — covering the full range from either end. +- **Mode-driven envelope shape.** The filter envelope follows the playback + mode exactly as the amp does: **Gate → AHDSR, Trigger → AHD** — see item 14, + where the policy is stated in full. *(Settled by item 14's follow-up.)* - **Per-voice.** The filter processes **per voice** — each sounding voice runs its own filter with its own envelope state. *(Settled by follow-up.)* - **Per-zone storage.** The filter's parameters are **stored per-zone**, @@ -654,9 +671,9 @@ change that turns the pitch envelope from AD into AHD**. into or immediately after that work; item 1's sloped-segment rule reads Attack and Decay for the pitch AHD (Hold is flat, no curve dial). Item 3's Spline overlays are unaffected by construction: a spline always spans the - full sample width already. Item 14 extends the AHD to the amp envelope's - Trigger mode, so this scope rule selects by the envelope's current shape - under the active playback mode — see item 14. + full sample width already. Item 14 extends the AHD to the amp and filter + envelopes' Trigger mode, so this scope rule selects by the envelope's + current shape under the active playback mode — see item 14. **Open questions.** @@ -933,6 +950,10 @@ high-DPI, high-resolution displays. Daniel named the visibly pixely surfaces: > (with curves of course), to reduce code for the same thing, and consolidate > under the new design. +**Daniel's follow-up (verbatim, 2026-07-28).** + +> sure, filter follows as well + **Intent.** Consolidation. In Trigger mode the amp envelope's fade-in-length / fade-out-length pair is retired and replaced by the same three-stage Attack → Hold → Decay envelope item 8 specifies for the pitch envelope — with @@ -940,7 +961,8 @@ item 1's per-segment curves. Daniel's stated motive is reducing code for the same job: one staged-envelope design covering what is currently two separate mechanisms doing the same thing. The product consequence: **the amp envelope's staged shape follows the playback mode** — Gate → AHDSR (sustain, -right-anchored release, per item 8); Trigger → AHD (sustain-less). +right-anchored release, per item 8); Trigger → AHD (sustain-less). The +follow-up round extends the same rule to item 2's filter envelope. **Behavior.** @@ -956,17 +978,24 @@ right-anchored release, per item 8); Trigger → AHD (sustain-less). - **Gate mode unchanged.** In Gate mode the amp envelope remains the AHDSR with item 8's right-anchored release. Item 9's loop-sustain spec (a Gate-mode feature) is untouched. -- **Item 8's scope rule now covers the amp envelope literally.** Item 8 +- **The filter envelope follows as well.** Item 2's filter envelope takes the + same mode-driven shape: **Gate → AHDSR, Trigger → AHD** — same staged + machinery, same consolidation. It necessarily lands with or after item 2 + (the filter must exist first); item 2 carries a cross-reference. *(Settled + by follow-up.)* +- **Item 8's scope rule now governs all three envelopes uniformly.** Item 8 settled that the 1:1-overlay property and the by-construction combined-time bound apply to envelopes **without a sustain stage**, and the right-anchored-release policy to those **with** one — Daniel's rationale: "the 1:1 mapping only makes sense for trigger, not gated envelopes." With - this item the amp envelope in Trigger mode *is* sustain-less, so it takes - the 1:1 time-mapped overlay automatically; in Gate mode it keeps the - right-anchored release. The rationale now describes the amp envelope - directly, not just by analogy: the rule selects by the envelope's **current - shape under the active playback mode**, not by which processor the envelope - modulates. No new rule is needed — item 8's rule already decides both cases. + this item the amp — and, per the follow-up, the filter — envelope in + Trigger mode *is* sustain-less, so each takes the 1:1 time-mapped overlay + automatically; in Gate mode each keeps the right-anchored release. The + general policy, stated once: **pitch is always AHD (1:1 overlay); amp and + filter are AHDSR in Gate (right-anchored release) and AHD in Trigger (1:1 + overlay)**. The rule selects by the envelope's **current shape under the + active playback mode**, not by which processor the envelope modulates. No + new rule is needed — item 8's rule already decides every case. - **Pre-existing instances reopen sounding identical.** The doc's standing migration framing (items 1, 2, 11) applies: a project saved before this change reopens with unchanged audible behavior — in particular, a Trigger @@ -989,20 +1018,21 @@ right-anchored release, per item 8); Trigger → AHD (sustain-less). **Open questions.** -- **Does the filter AHDSR follow the same mode-driven shape?** Item 2's - filter envelope is the same staged machinery, so Gate → AHDSR / - Trigger → AHD would extend naturally — but Daniel said this only for the - amp envelope, and it is not asserted here. **Awaits a Daniel decision** — - currently the doc's only Daniel-pending question. - **Stage-value state across the Gate/Trigger switch — shared or per-mode?** The Gate AHDSR and Trigger AHD share stage names (A, H, D); whether they share *values* (one envelope whose S and R fall away in Trigger) or keep per-mode state (item 3's "save but inactive" dual-state precedent) is - unspecified. Migration leans per-mode: an old instance carries both its - AHDSR values and its Trigger fade values, and a shared-value model cannot - preserve both modes' prior sound at once. Resolve at implementation review - with a proposal — not Daniel-blocking, but the sound-identical gate must - hold for whichever mode a saved instance plays in. + unspecified — and with the follow-up the question covers the **filter + envelope too**, not just the amp. Migration leans per-mode for the amp: an + old instance carries both its AHDSR values and its Trigger fade values, and + a shared-value model cannot preserve both modes' prior sound at once. (The + filter is new in item 2, so it carries no migration weight either way; + matching the amp's answer is the natural default.) Resolve at + implementation review with a proposal — not Daniel-blocking, but the + sound-identical gate must hold for whichever mode a saved instance plays + in. (The filter question that previously led this list — does the filter + AHDSR follow the same mode-driven shape? — is settled by the follow-up: + yes; folded into Behavior above.) **Acceptance criteria.** @@ -1016,6 +1046,9 @@ right-anchored release, per item 8); Trigger → AHD (sustain-less). right-anchored release per item 8. - Switching between Gate and Trigger playback switches the amp envelope surface (deck and overlay) between AHDSR and AHD accordingly. +- Once item 2's filter lands, its envelope switches identically: AHDSR + (right-anchored release) in Gate, 1:1 AHD in Trigger — the mode switch + swaps the amp and filter envelope surfaces the same way. - A project saved before this change reopens sounding identical: a Trigger instance's prior fade-in/fade-out contour — including a zero fade-out's abrupt end — is audibly reproduced by the loaded AHD. From 67a41728f3f72183157f7cb652ee8b8d2c38c821 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 19:59:02 -0400 Subject: [PATCH 18/40] =?UTF-8?q?Q-W1=20pt1:=20extract=20core/json=20(json?= =?UTF-8?q?::Reader/Writer),=20collapse=20wire=20Cursor=20family=20into=20?= =?UTF-8?q?core/wire,=20shared=20readFileBytes=20=E2=80=94=20five=20JSON?= =?UTF-8?q?=20decoders=20and=20three=20cursor=20copies=20deleted,=20byte-i?= =?UTF-8?q?dentical=20formats,=2059/59=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 48 ++- src/assignment_request.cpp | 116 +------- src/bank_book.cpp | 374 +++++------------------- src/bank_model.cpp | 484 ++++++------------------------- src/capture.cpp | 18 +- src/capture_realtime.cpp | 22 +- src/core/json/json.cpp | 319 ++++++++++++++++++++ src/core/json/json.h | 149 ++++++++++ src/core/util/file_bytes.cpp | 21 ++ src/core/util/file_bytes.h | 19 ++ src/core/wire/wire.cpp | 134 +++++++++ src/core/wire/wire.h | 88 ++++++ src/ingest.cpp | 16 +- src/owned_manifest.cpp | 248 ++-------------- src/provenance.cpp | 121 +------- src/sample_usage.cpp | 88 +----- src/tail_control.cpp | 87 +++--- src/view_mode_model.cpp | 436 ++++++---------------------- src/vst/bank_sync.cpp | 21 +- src/vst/reasampler_editor.cpp | 13 +- src/vst/reasampler_processor.cpp | 17 +- tests/test_file_bytes.cpp | 51 ++++ tests/test_json.cpp | 296 +++++++++++++++++++ tests/test_tail_control.cpp | 15 + tests/test_wire.cpp | 190 ++++++++++++ 25 files changed, 1695 insertions(+), 1696 deletions(-) create mode 100644 src/core/json/json.cpp create mode 100644 src/core/json/json.h create mode 100644 src/core/util/file_bytes.cpp create mode 100644 src/core/util/file_bytes.h create mode 100644 src/core/wire/wire.cpp create mode 100644 src/core/wire/wire.h create mode 100644 tests/test_file_bytes.cpp create mode 100644 tests/test_json.cpp create mode 100644 tests/test_wire.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 12c69f8..4c17901 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,12 +82,34 @@ set(SDK_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/reaper-sdk/sdk) set(WDL_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL) set(SWELL ${WDL_INC}/swell) +# --------------------------------------------------------------------------- +# 0) core/ — the Q-W1 shared pure substrate. NO REAPER, NO SWELL, NO VST3. +# json: the ONE JSON lexical layer (reader + writer) behind the five +# persisted-blob (de)serializers (bank_model / bank_book / +# view_mode_model / owned_manifest / tail_control). T2-02 / §2. +# wire: the ONE length-prefixed ext-state wire codec (putField + Cursor + +# the guarded decimal accumulate) behind provenance / +# assignment_request / sample_usage / bank_sync. T2-01(b). +# file_bytes: the ONE whole-file byte loader both artifacts link. T2-03. +# Headers are included as "core/json/json.h" etc. (rooted at src/), so the +# include paths survive the Q-W1 part-2 directory relocation unchanged. +# --------------------------------------------------------------------------- +add_library(json STATIC src/core/json/json.cpp) +target_include_directories(json PUBLIC src) + +add_library(wire STATIC src/core/wire/wire.cpp) +target_include_directories(wire PUBLIC src) + +add_library(file_bytes STATIC src/core/util/file_bytes.cpp) +target_include_directories(file_bytes PUBLIC src) + # --------------------------------------------------------------------------- # 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere. # The sampler's heart: Sample metadata + BankIndex (Milestone 1). # --------------------------------------------------------------------------- add_library(bank_model STATIC src/bank_model.cpp) target_include_directories(bank_model PUBLIC src) +target_link_libraries(bank_model PRIVATE json) # --------------------------------------------------------------------------- # 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from @@ -142,6 +164,7 @@ target_include_directories(tab_strip PUBLIC src) # --------------------------------------------------------------------------- add_library(view_mode_model STATIC src/view_mode_model.cpp) target_include_directories(view_mode_model PUBLIC src) +target_link_libraries(view_mode_model PRIVATE json) # The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE # durable-key convention in lane_keys (laneNameForMode), so the model depends on that # pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol. @@ -224,6 +247,7 @@ target_include_directories(batch_capture PUBLIC src) add_library(tail_control STATIC src/tail_control.cpp) target_include_directories(tail_control PUBLIC src) target_link_libraries(tail_control PUBLIC render_settings) +target_link_libraries(tail_control PRIVATE json) # --------------------------------------------------------------------------- # 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart @@ -236,6 +260,7 @@ target_link_libraries(tail_control PUBLIC render_settings) add_library(bank_book STATIC src/bank_book.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) +target_link_libraries(bank_book PRIVATE json) # --------------------------------------------------------------------------- # 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest @@ -248,6 +273,7 @@ target_link_libraries(bank_book PUBLIC bank_model) # --------------------------------------------------------------------------- add_library(owned_manifest STATIC src/owned_manifest.cpp) target_include_directories(owned_manifest PUBLIC src) +target_link_libraries(owned_manifest PRIVATE json) # --------------------------------------------------------------------------- # 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The @@ -323,6 +349,7 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge # --------------------------------------------------------------------------- add_library(provenance STATIC src/provenance.cpp) target_include_directories(provenance PUBLIC src) +target_link_libraries(provenance PRIVATE wire) # --------------------------------------------------------------------------- # 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest @@ -336,6 +363,7 @@ target_include_directories(provenance PUBLIC src) # --------------------------------------------------------------------------- add_library(assignment_request STATIC src/assignment_request.cpp) target_include_directories(assignment_request PUBLIC src) +target_link_libraries(assignment_request PRIVATE wire) # --------------------------------------------------------------------------- # 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam: @@ -349,6 +377,7 @@ target_include_directories(assignment_request PUBLIC src) # --------------------------------------------------------------------------- add_library(sample_usage STATIC src/sample_usage.cpp) target_include_directories(sample_usage PUBLIC src) +target_link_libraries(sample_usage PRIVATE wire) # --------------------------------------------------------------------------- # 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11 @@ -538,6 +567,20 @@ target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve) # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- enable_testing() + +# core/ (Q-W1): the shared JSON lexical layer, wire codec, and file loader. +add_executable(json_tests tests/test_json.cpp) +target_link_libraries(json_tests PRIVATE json) +add_test(NAME json_tests COMMAND json_tests) + +add_executable(wire_tests tests/test_wire.cpp) +target_link_libraries(wire_tests PRIVATE wire) +add_test(NAME wire_tests COMMAND wire_tests) + +add_executable(file_bytes_tests tests/test_file_bytes.cpp) +target_link_libraries(file_bytes_tests PRIVATE file_bytes) +add_test(NAME file_bytes_tests COMMAND file_bytes_tests) + add_executable(bank_model_tests tests/test_bank_model.cpp) target_link_libraries(bank_model_tests PRIVATE bank_model) add_test(NAME bank_model_tests COMMAND bank_model_tests) @@ -822,6 +865,7 @@ target_link_libraries(waveform_view PUBLIC editor_geometry peaks) add_library(bank_sync STATIC src/vst/bank_sync.cpp) target_include_directories(bank_sync PUBLIC src/vst src) target_link_libraries(bank_sync PUBLIC assignment_request) +target_link_libraries(bank_sync PRIVATE wire) # browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search # geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect + @@ -1039,7 +1083,7 @@ add_library(reaper_reasampler MODULE src/card_drag.cpp src/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest 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) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest 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) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' @@ -1201,7 +1245,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") sample_map capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit - knob_deck curve_popup master_gain sample_usage) + knob_deck curve_popup master_gain sample_usage file_bytes) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp index 077aeee..bbe5775 100644 --- a/src/assignment_request.cpp +++ b/src/assignment_request.cpp @@ -2,8 +2,7 @@ #include "assignment_request.h" -#include -#include +#include "core/wire/wire.h" namespace reasampler { @@ -11,114 +10,11 @@ namespace { constexpr const char* kMagic = "rsassign1"; -// Append one length-prefixed field: ':' . Mirror of -// provenance's putField so the two seams share one wire idiom. -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} - -// Cursor over the encoded string. All reads are bounds-checked; a short read fails -// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the -// three field kinds this record needs. -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or - // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past - // the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a - // 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via - // repeated multiply. "never UB" promise from the header is upheld here. - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. - if (colon - pos_ > 20u) return fail(); - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - // Guard: start may equal s_.size() (empty remainder), in which case only len==0 - // is valid; start > s_.size() cannot happen (colon < s_.size() by find()). - // Use subtraction-first form to avoid start+len wrapping on a huge len. - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - // Reads a length-prefixed field and parses it as a signed 64-bit decimal (an - // optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value - // that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped - // at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20 - // characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB" - // promise from the header is upheld: all arithmetic is done on positive digits - // and capped before applying the sign. - bool fieldInt64(std::int64_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty()) return fail(); - std::size_t i = 0; - bool neg = false; - if (f[0] == '-') { - neg = true; - i = 1; - if (f.size() == 1) return fail(); // bare "-" - } - // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit - // positive value would overflow INT64_MAX; a 20-digit negative might be valid - // (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the - // generation field is a unix timestamp, never near INT64 limits in practice. - if (f.size() - i > 19u) return fail(); - std::int64_t v = 0; - for (; i < f.size(); ++i) { - const char c = f[i]; - if (c < '0' || c > '9') return fail(); - const std::int64_t digit = static_cast(c - '0'); - // Overflow guard: v * 10 + digit must not exceed INT64_MAX. - if (v > (std::numeric_limits::max() - digit) / 10) - return fail(); - v = v * 10 + digit; - } - out = neg ? -v : v; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - std::size_t i = 0; - for (; lit[i] != '\0'; ++i) { - if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); - } - pos_ += i; - return true; - } - -private: - bool fail() { - ok_ = false; - return false; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; +// The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening +// this file previously carried as its own Cursor copy. "never UB, never a +// partial value" is upheld in the codec. +using wire::putField; +using Cursor = wire::Cursor; } // namespace diff --git a/src/bank_book.cpp b/src/bank_book.cpp index 786e265..df5b3a9 100644 --- a/src/bank_book.cpp +++ b/src/bank_book.cpp @@ -1,14 +1,14 @@ #include "bank_book.h" #include -#include #include +#include "core/json/json.h" + // bank_book implementation. // -// JSON is hand-rolled and self-contained, matching the house style of bank_model -// and view_mode_model (brief: keep the pure core dependency-free — no third-party -// JSON lib). The book blob nests one bank object per bank, each carrying that +// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model +// and view_mode_model. The book blob nests one bank object per bank, each carrying that // bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize), // so per-bank sample serialization stays owned by bank_model and is not duplicated // here. The book writer emits the bank envelope (id / displayName / ordinal) plus a @@ -138,7 +138,7 @@ SlotMap SlotMap::fromEntries(const std::vector>& pai } // SlotMap::serialize is defined in the JSON writer section below (it reuses the -// file-local ObjWriter / intToStr helpers). +// shared core/json emit helpers). // --------------------------------------------------------------------------- // BankBook — construction + bank lookup @@ -550,64 +550,10 @@ std::vector BankBook::referencedPaths() const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string intToStr(int v) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%d", v); - return buf; -} - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { if (first_) first_ = false; else out_ += ','; } - std::string& out_; - bool first_ = true; -}; +// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the +// prior file-local writer carried, so the emitted blob is byte-identical. +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; } // namespace @@ -660,223 +606,38 @@ std::string BankBook::serialize() const { namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} +// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). +// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, +// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive +// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root +// blob, distinguishing the legacy shape (a bare bank_index object: has +// "samples", no "banks") from the book shape (has "banks"): a legacy blob +// yields a single pool bank carrying the migrated index and an empty active id +// (⇒ pool). The member deserialize() adopts the result (ordinal normalize + +// active resolve). +bool parseSlots(json::Reader& r, std::vector>& out); - // Parses a book blob into a bank set + active id. On success fills the out-params - // and returns true. Distinguishes the legacy shape (a bare bank_index object: has - // "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a - // single pool bank carrying the migrated index and an empty active id (⇒ pool). The - // member deserialize() adopts the result (ordinal normalize + active resolve). - bool parseBook(std::vector& banks, std::string& activeBank); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseInt(int& out); - bool parseKey(std::string& key); - bool skipValue(); - // Captures the raw source text of one JSON value (object / array / string / - // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser. - bool captureValue(std::string& raw); - - bool parseBank(Bank& out); - // Parses the "slots" array ([{id, slot}, ...]) into (id, slot) pairs. An empty - // array is valid (an empty bank). Malformed structure fails the whole parse; the - // pair-level defensive repair (dupes/conflicts) lives in SlotMap::fromEntries. - bool parseSlots(std::vector>& out); -}; - -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - unsigned int hi = 0; - if (!readHex4(hi)) return false; - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate - } - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated -} - -bool Parser::parseInt(int& out) { - skipWs(); - std::size_t start = pos_; - if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_; - std::size_t digitsStart = pos_; - while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; - if (pos_ == digitsStart) return false; // no digits - long v = 0; - try { - v = std::stol(s_.substr(start, pos_ - start)); - } catch (...) { - return false; // out of long range → malformed - } - if (v < INT_MIN || v > INT_MAX) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - if (!consume(':')) return false; - return true; -} - -bool Parser::skipValue() { - std::string raw; - return captureValue(raw); -} - -// Records the raw source span of one JSON value starting at the current position -// (after whitespace) so it can be re-parsed by a nested parser. Handles nested -// objects/arrays with string-aware brace matching (braces inside strings ignored). -bool Parser::captureValue(std::string& raw) { - skipWs(); - if (eof()) return false; - std::size_t start = pos_; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - if (!parseString(tmp)) return false; - raw.assign(s_, start, pos_ - start); - return true; - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { - std::string tmp; - if (!parseString(tmp)) return false; // advances past the string - continue; - } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - if (depth != 0) return false; - raw.assign(s_, start, pos_ - start); - return true; - } - // bare scalar (number / true / false / null) - while (!eof()) { - char d = s_[pos_]; - if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || - d == '\n' || d == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - raw.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseBank(Bank& b) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return false; // a bank object must at least carry an id +bool parseBank(json::Reader& r, Bank& b) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // a bank object must at least carry an id bool haveId = false; bool haveIndex = false; do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "id") { - if (!parseString(b.id)) return false; + if (!r.parseString(b.id)) return false; haveId = true; } else if (key == "displayName") { - if (!parseString(b.displayName)) return false; + if (!r.parseString(b.displayName)) return false; } else if (key == "ordinal") { - if (!parseInt(b.ordinal)) return false; + if (!r.parseInt(b.ordinal)) return false; } else if (key == "index") { std::string raw; - if (!captureValue(raw)) return false; + if (!r.captureValue(raw)) return false; auto idx = BankIndex::deserialize(raw); if (!idx) return false; // a malformed nested index fails the whole parse b.index = std::move(*idx); @@ -886,49 +647,50 @@ bool Parser::parseBank(Bank& b) { // nothing because the key never appears); when present it drives the // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. std::vector> pairs; - if (!parseSlots(pairs)) return false; + if (!parseSlots(r, pairs)) return false; b.slots = SlotMap::fromEntries(pairs); } else { - if (!skipValue()) return false; // forward-compat unknown keys + if (!r.skipValue()) return false; // forward-compat unknown keys } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; + if (!r.consume('}')) return false; if (!haveId || b.id.empty()) return false; // id keys the registry if (!haveIndex) return false; // every bank persists its index return true; } -bool Parser::parseSlots(std::vector>& out) { +bool parseSlots(json::Reader& r, std::vector>& out) { out.clear(); - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty slot array — a bank with no positions yet + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty slot array — a bank with no positions yet do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string id; int slot = 0; bool haveId = false, haveSlot = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "id") { if (!parseString(id)) return false; haveId = true; } - else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; } - else { if (!skipValue()) return false; } // forward-compat - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } + else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } + else { if (!r.skipValue()) return false; } // forward-compat + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveId || !haveSlot) return false; // a slot entry needs both out.emplace_back(std::move(id), slot); - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseBook(std::vector& banks, std::string& activeBank) { +bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks, + std::string& activeBank) { banks.clear(); activeBank.clear(); - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return false; // an empty object is neither shape → malformed + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // an empty object is neither shape → malformed // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). @@ -938,41 +700,41 @@ bool Parser::parseBook(std::vector& banks, std::string& activeBank) { do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "banks") { sawBanks = true; - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { Bank b; - if (!parseBank(b)) return false; + if (!parseBank(r, b)) return false; parsedBanks.push_back(std::move(b)); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } else if (key == "activeBank") { - if (!parseString(activeBank)) return false; + if (!r.parseString(activeBank)) return false; } else if (key == "samples") { // Legacy marker. The legacy index is re-parsed from the whole input below // (BankIndex::deserialize owns that shape); here we only skip the value to // keep the scan well-formed and note that we saw it. sawSamples = true; - if (!skipValue()) return false; + if (!r.skipValue()) return false; } else { - if (!skipValue()) return false; // version, or unknown + if (!r.skipValue()) return false; // version, or unknown } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; - skipWs(); - if (!eof()) return false; // trailing garbage + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- if (!sawBanks) { if (!sawSamples) return false; // neither shape's marker → malformed - auto legacy = BankIndex::deserialize(s_); + auto legacy = BankIndex::deserialize(raw); if (!legacy) return false; Bank pool; pool.id = kPoolBankId; @@ -1059,11 +821,11 @@ void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBa activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); } -std::optional BankBook::deserialize(const std::string& json) { +std::optional BankBook::deserialize(const std::string& blob) { std::vector banks; std::string activeBank; - Parser p(json); - if (!p.parseBook(banks, activeBank)) return std::nullopt; + json::Reader r(blob); + if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; BankBook book; book.adoptBanks(std::move(banks), activeBank); diff --git a/src/bank_model.cpp b/src/bank_model.cpp index a5286ec..07f92ce 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -1,17 +1,15 @@ #include "bank_model.h" #include -#include -#include -#include -#include + +#include "core/json/json.h" // bank_model implementation. // -// JSON is hand-rolled and self-contained (brief: keep the pure core -// dependency-free — no third-party JSON lib, no WDL coupling). The field set is -// a flat struct of primitives, strings, one enum, a small string array, and a -// few optionals, so a compact writer + recursive-descent parser is the simplest +// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer, +// no per-module Parser copy). The field set is a flat struct of primitives, +// strings, one enum, a small string array, and a few optionals, so a compact +// writer + recursive-descent DOMAIN parser over json::Reader is the simplest // thing that works. Doubles are emitted with 17 significant digits (%.17g), the // shortest form that round-trips every IEEE-754 double exactly, so the // deserialize(serialize(x)) == x invariant holds bit-for-bit. @@ -147,84 +145,10 @@ std::vector BankIndex::byTier(Tier tier) const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string numToStr(double v) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.17g", v); - return buf; -} - -std::string numToStr(std::int64_t v) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); - return buf; -} - -std::string numToStr(int v) { return numToStr(static_cast(v)); } - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - // Begin a nested value; caller writes the value immediately after. - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { - if (first_) first_ = false; else out_ += ','; - } - std::string& out_; - bool first_ = true; -}; - -void writeStringArray(std::string& out, const std::vector& v) { - out += '['; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i) out += ','; - writeEscaped(out, v[i]); - } - out += ']'; -} +using json::numToStr; +using json::writeEscaped; +using json::writeStringArray; +using ObjWriter = json::Writer; void writeSample(std::string& out, const Sample& s) { ObjWriter w(out); @@ -317,346 +241,116 @@ std::string BankIndex::serialize() const { } // --------------------------------------------------------------------------- -// JSON parser (recursive descent). Returns false on any malformed input; never -// reads out of bounds. Only supports the subset our writer emits. +// JSON parser (recursive descent over the shared json::Reader). Returns false +// on any malformed input; never reads out of bounds. Only supports the subset +// our writer emits. The lexical layer (strings, numbers, skip) lives in +// core/json; only the Sample/index DOMAIN grammar lives here. // --------------------------------------------------------------------------- namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - bool parseIndex(BankIndex& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - char peek() const { return s_[pos_]; } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseRawScalar(std::string& out); // number / true / false / null token - bool parseDouble(double& out); - bool parseInt64(std::int64_t& out); - bool parseInt(int& out); - bool parseBool(bool& out); - bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present - - bool parseSample(Sample& out); - bool parseKey(std::string& key); // an object member key + ':' - bool skipValue(); // for forward-compat unknown keys -}; - -// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX -// for control chars). Positioned at the opening quote after whitespace. -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - // Decode a \uXXXX escape to its code point. - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - - unsigned int hi = 0; - if (!readHex4(hi)) return false; - - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - // High surrogate — must be followed by \uDC00–\uDFFF. - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate — malformed - } - - // Encode codePoint as UTF-8. - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated string -} - -// Reads a bare token (number, true, false, null) up to the next structural char. -bool Parser::parseRawScalar(std::string& out) { - skipWs(); - std::size_t start = pos_; - while (!eof()) { - char c = s_[pos_]; - if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || - c == '\n' || c == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - out.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseDouble(double& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - double v = std::strtod(b, &end); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; // overflow / underflow → malformed - out = v; - return true; -} - -bool Parser::parseInt64(std::int64_t& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - long long v = std::strtoll(b, &end, 10); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; // overflow → malformed - out = static_cast(v); - return true; -} - -bool Parser::parseInt(int& out) { - std::int64_t v = 0; - if (!parseInt64(v)) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseBool(bool& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - if (tok == "true") { out = true; return true; } - if (tok == "false") { out = false; return true; } - return false; -} - -// If the next value is the `null` token, consumes it and sets wasNull=true. -// Otherwise leaves the position untouched and sets wasNull=false. Returns false -// only on eof. -bool Parser::expectNullOr(bool& wasNull) { - skipWs(); - if (eof()) return false; - if (s_.compare(pos_, 4, "null") == 0) { - pos_ += 4; - wasNull = true; - } else { - wasNull = false; - } - return true; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - if (!consume(':')) return false; - return true; -} - -// Skips one JSON value (object / array / string / scalar) for forward-compat -// with keys we don't recognize. Assumes position is at the start of the value. -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - return parseString(tmp); - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { - std::string tmp; - if (!parseString(tmp)) return false; - continue; - } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - return depth == 0; - } - std::string tmp; - return parseRawScalar(tmp); -} - -bool Parser::parseSample(Sample& s) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object (shouldn't happen, but valid) +bool parseSample(json::Reader& r, Sample& s) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object (shouldn't happen, but valid) do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "id") { - if (!parseString(s.id)) return false; + if (!r.parseString(s.id)) return false; } else if (key == "displayName") { - if (!parseString(s.displayName)) return false; + if (!r.parseString(s.displayName)) return false; } else if (key == "relativePath") { - if (!parseString(s.relativePath)) return false; + if (!r.parseString(s.relativePath)) return false; } else if (key == "sourceMode") { int v = 0; - if (!parseInt(v)) return false; + if (!r.parseInt(v)) return false; // Valid range: MasterMix(0) .. Realtime(5). if (v < static_cast(SourceMode::MasterMix) || v > static_cast(SourceMode::Realtime)) return false; s.sourceMode = static_cast(v); } else if (key == "sourceRange") { - if (!consume('{')) return false; + if (!r.consume('{')) return false; do { std::string rk; - if (!parseKey(rk)) return false; + if (!r.parseKey(rk)) return false; double dv = 0.0; - if (!parseDouble(dv)) return false; + if (!r.parseDouble(dv)) return false; if (rk == "startSeconds") s.sourceRange.startSeconds = dv; else if (rk == "endSeconds") s.sourceRange.endSeconds = dv; else if (rk == "startPpq") s.sourceRange.startPpq = dv; else if (rk == "endPpq") s.sourceRange.endPpq = dv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; } else if (key == "trackGuids") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { std::string g; - if (!parseString(g)) return false; + if (!r.parseString(g)) return false; s.trackGuids.push_back(g); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } else if (key == "wetDry") { - if (!parseDouble(s.wetDry)) return false; + if (!r.parseDouble(s.wetDry)) return false; } else if (key == "channelCount") { - if (!parseInt(s.channelCount)) return false; + if (!r.parseInt(s.channelCount)) return false; } else if (key == "sampleRate") { - if (!parseInt(s.sampleRate)) return false; + if (!r.parseInt(s.sampleRate)) return false; } else if (key == "lengthSeconds") { - if (!parseDouble(s.lengthSeconds)) return false; + if (!r.parseDouble(s.lengthSeconds)) return false; } else if (key == "lengthBeats") { - if (!parseDouble(s.lengthBeats)) return false; + if (!r.parseDouble(s.lengthBeats)) return false; } else if (key == "captureTempo") { - if (!parseDouble(s.captureTempo)) return false; + if (!r.parseDouble(s.captureTempo)) return false; } else if (key == "captureTimeSigNum") { - if (!parseInt(s.captureTimeSigNum)) return false; + if (!r.parseInt(s.captureTimeSigNum)) return false; } else if (key == "captureTimeSigDenom") { - if (!parseInt(s.captureTimeSigDenom)) return false; + if (!r.parseInt(s.captureTimeSigDenom)) return false; } else if (key == "key") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.key.reset(); } else { std::string k; - if (!parseString(k)) return false; + if (!r.parseString(k)) return false; s.key = k; } } else if (key == "rootNote") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.rootNote.reset(); } else { int v = 0; - if (!parseInt(v)) return false; + if (!r.parseInt(v)) return false; // Valid MIDI note range: 0..127 inclusive (boundaries valid). if (v < 0 || v > 127) return false; s.rootNote = v; } } else if (key == "loop") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.loop.reset(); } else { - if (!consume('{')) return false; + if (!r.consume('{')) return false; LoopPoints lp; do { std::string lk; - if (!parseKey(lk)) return false; + if (!r.parseKey(lk)) return false; std::int64_t lv = 0; - if (!parseInt64(lv)) return false; + if (!r.parseInt64(lv)) return false; if (lk == "start") lp.start = lv; else if (lk == "end") lp.end = lv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; // Invariant: 0 <= start <= end. start == end is a valid zero-length // marker; a negative index or start > end is malformed, not silently // clamped (mirrors the enum-range rejection above). @@ -664,89 +358,89 @@ bool Parser::parseSample(Sample& s) { s.loop = lp; } } else if (key == "levels") { - if (!consume('{')) return false; + if (!r.consume('{')) return false; do { std::string lk; - if (!parseKey(lk)) return false; + if (!r.parseKey(lk)) return false; double dv = 0.0; - if (!parseDouble(dv)) return false; + if (!r.parseDouble(dv)) return false; if (lk == "peakDb") s.levels.peakDb = dv; else if (lk == "rmsDb") s.levels.rmsDb = dv; else if (lk == "lufs") s.levels.lufs = dv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; } else if (key == "clipped") { - if (!parseBool(s.clipped)) return false; + if (!r.parseBool(s.clipped)) return false; } else if (key == "tier") { int v = 0; - if (!parseInt(v)) return false; + if (!r.parseInt(v)) return false; // Valid range: Scratch(0) .. Archive(1). if (v < static_cast(Tier::Scratch) || v > static_cast(Tier::Archive)) return false; s.tier = static_cast(v); } else if (key == "contentHash") { - if (!parseString(s.contentHash)) return false; + if (!r.parseString(s.contentHash)) return false; } else if (key == "provenance") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.provenance.reset(); } else { - if (!consume('{')) return false; + if (!r.consume('{')) return false; Provenance p; do { std::string pk; - if (!parseKey(pk)) return false; + if (!r.parseKey(pk)) return false; std::string pv; - if (!parseString(pv)) return false; + if (!r.parseString(pv)) return false; if (pk == "parentSampleId") p.parentSampleId = pv; else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; s.provenance = p; } } else if (key == "createdTimestamp") { - if (!parseInt64(s.createdTimestamp)) return false; + if (!r.parseInt64(s.createdTimestamp)) return false; } else { - if (!skipValue()) return false; // forward-compat: ignore unknown + if (!r.skipValue()) return false; // forward-compat: ignore unknown } - } while (consume(',')); + } while (r.consume(',')); - return consume('}'); + return r.consume('}'); } -bool Parser::parseIndex(BankIndex& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object — vacuously an empty index +bool parseIndex(json::Reader& r, BankIndex& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object — vacuously an empty index std::vector parsed; do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "samples") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { Sample s; - if (!parseSample(s)) return false; + if (!parseSample(r, s)) return false; parsed.push_back(std::move(s)); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } else { - if (!skipValue()) return false; // version, or unknown keys + if (!r.skipValue()) return false; // version, or unknown keys } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; + if (!r.consume('}')) return false; // Trailing garbage after the root object is malformed. - skipWs(); - if (!eof()) return false; + r.skipWs(); + if (!r.eof()) return false; // Rebuild via add() so the same invariants (relative-path, dedup) that guard // live inserts also guard deserialized data. Rejected/collapsed entries are @@ -757,10 +451,10 @@ bool Parser::parseIndex(BankIndex& out) { } // namespace -std::optional BankIndex::deserialize(const std::string& json) { +std::optional BankIndex::deserialize(const std::string& blob) { BankIndex idx; - Parser p(json); - if (!p.parseIndex(idx)) return std::nullopt; + json::Reader r(blob); + if (!parseIndex(r, idx)) return std::nullopt; return idx; } diff --git a/src/capture.cpp b/src/capture.cpp index 61c9113..b53cb46 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -43,6 +43,7 @@ #include #include "capture_paths.h" +#include "core/util/file_bytes.h" #include "render_settings.h" #define REAPERAPI_MINIMAL @@ -226,20 +227,9 @@ std::string makeUniqueTag() { return std::to_string(static_cast(now)); } -// Reads the whole file into a byte buffer. Returns an empty vector on any I/O -// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting -// direction for an unreadable file). -std::vector readFileBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamoff size = f.tellg(); - if (size <= 0) return {}; - std::vector bytes(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), size); - if (!f) return {}; - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): +// empty on any I/O failure (the caller then leaves contentHash empty — the safe, +// confirm-eliciting direction for an unreadable file). } // namespace diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp index 4a5e16f..de084e8 100644 --- a/src/capture_realtime.cpp +++ b/src/capture_realtime.cpp @@ -79,6 +79,7 @@ #include #include "capture_paths.h" // hashBytes, deriveBankPaths +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "peaks.h" // lastFrameAboveThreshold, AudioSample #include "realtime_record.h" #include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd @@ -323,20 +324,9 @@ private: namespace { -// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the -// caller treats an unreadable file as "skip the trim" (keep the untrimmed window), -// never as a corruption of the recorded audio. -std::vector readAllBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamoff size = f.tellg(); - if (size <= 0) return {}; - std::vector bytes(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), size); - if (!f) return {}; - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): +// empty on any I/O failure — the caller treats an unreadable file as "skip the +// trim" (keep the untrimmed window), never as a corruption of the recorded audio. // Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). void writeU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { @@ -371,7 +361,7 @@ double trimAutoTailInPlace(const std::string& path, double rangeEndSeconds) { constexpr double kNoTrim = -1.0; - std::vector bytes = readAllBytes(path); + std::vector bytes = readFileBytes(path); if (bytes.empty()) return kNoTrim; const reasampler::WavLayout layout = parseWavLayout(bytes); @@ -533,7 +523,7 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) { // contentHash empty — the safe, confirm-eliciting direction (bank_model treats // "" as non-participating). { - const std::vector fileBytes = readAllBytes(destPath); + const std::vector fileBytes = readFileBytes(destPath); if (!fileBytes.empty()) { result.sample.contentHash = hashWavContent(fileBytes); } diff --git a/src/core/json/json.cpp b/src/core/json/json.cpp new file mode 100644 index 0000000..ad3d6bb --- /dev/null +++ b/src/core/json/json.cpp @@ -0,0 +1,319 @@ +// core/json implementation — see json.h. The bodies are the (previously +// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any +// behavioral change here changes five persisted-blob parsers at once. + +#include "core/json/json.h" + +#include +#include +#include +#include + +namespace reasampler::json { + +// --------------------------------------------------------------------------- +// emit helpers +// --------------------------------------------------------------------------- + +void writeEscaped(std::string& out, const std::string& s) { + out += '"'; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + } + } + out += '"'; +} + +std::string numToStr(double v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", v); + return buf; +} + +std::string numToStr(std::int64_t v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); + return buf; +} + +std::string numToStr(int v) { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%d", v); + return buf; +} + +void writeStringArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + writeEscaped(out, v[i]); + } + out += ']'; +} + +void writeIntArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + out += numToStr(v[i]); + } + out += ']'; +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +void Reader::skipWs() { + while (!eof()) { + char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; + else break; + } +} + +bool Reader::consume(char c) { + skipWs(); + if (eof() || s_[pos_] != c) return false; + ++pos_; + return true; +} + +// Parses a JSON string literal (with the escapes our writers emit, plus \uXXXX +// for control chars). Positioned before the opening quote (skips leading ws). +bool Reader::parseString(std::string& out) { + skipWs(); + if (eof() || s_[pos_] != '"') return false; + ++pos_; + out.clear(); + while (!eof()) { + char c = s_[pos_++]; + if (c == '"') return true; + if (c == '\\') { + if (eof()) return false; + char e = s_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + // Decode a \uXXXX escape to its code point. + auto readHex4 = [&](unsigned int& cp) -> bool { + if (pos_ + 4 > s_.size()) return false; + cp = 0; + for (int i = 0; i < 4; ++i) { + char h = s_[pos_++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else return false; + } + return true; + }; + + unsigned int hi = 0; + if (!readHex4(hi)) return false; + + unsigned int codePoint = hi; + if (hi >= 0xD800 && hi <= 0xDBFF) { + // High surrogate — must be followed by \uDC00–\uDFFF. + if (pos_ + 6 > s_.size()) return false; + if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; + pos_ += 2; + unsigned int lo = 0; + if (!readHex4(lo)) return false; + if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate + codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + } else if (hi >= 0xDC00 && hi <= 0xDFFF) { + return false; // unpaired low surrogate — malformed + } + + // Encode codePoint as UTF-8. + if (codePoint <= 0x7F) { + out += static_cast(codePoint); + } else if (codePoint <= 0x7FF) { + out += static_cast(0xC0 | (codePoint >> 6)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + out += static_cast(0xE0 | (codePoint >> 12)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else { + out += static_cast(0xF0 | (codePoint >> 18)); + out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } + break; + } + default: return false; + } + } else { + out += c; + } + } + return false; // unterminated string +} + +bool Reader::parseRawScalar(std::string& out) { + skipWs(); + std::size_t start = pos_; + while (!eof()) { + char c = s_[pos_]; + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || + c == '\n' || c == '\r') + break; + ++pos_; + } + if (pos_ == start) return false; + out.assign(s_, start, pos_ - start); + return true; +} + +bool Reader::parseDouble(double& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + double v = std::strtod(b, &end); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow / underflow -> malformed + out = v; + return true; +} + +bool Reader::parseInt64(std::int64_t& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + long long v = std::strtoll(b, &end, 10); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow -> malformed + out = static_cast(v); + return true; +} + +bool Reader::parseInt(int& out) { + std::int64_t v = 0; + if (!parseInt64(v)) return false; + if (v < INT_MIN || v > INT_MAX) return false; + out = static_cast(v); + return true; +} + +bool Reader::parseBool(bool& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + if (tok == "true") { out = true; return true; } + if (tok == "false") { out = false; return true; } + return false; +} + +bool Reader::expectNullOr(bool& wasNull) { + skipWs(); + if (eof()) return false; + if (s_.compare(pos_, 4, "null") == 0) { + pos_ += 4; + wasNull = true; + } else { + wasNull = false; + } + return true; +} + +bool Reader::parseKey(std::string& key) { + if (!parseString(key)) return false; + return consume(':'); +} + +bool Reader::parseStringArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; // empty array + do { + std::string s; + if (!parseString(s)) return false; + out.push_back(std::move(s)); + } while (consume(',')); + return consume(']'); +} + +bool Reader::parseIntArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + int v = 0; + if (!parseInt(v)) return false; + out.push_back(v); + } while (consume(',')); + return consume(']'); +} + +bool Reader::skipValue() { + std::string raw; + return captureValue(raw); +} + +// Records the raw source span of one JSON value starting at the current position +// (after whitespace). Handles nested objects/arrays with string-aware brace +// matching (braces inside strings ignored). +bool Reader::captureValue(std::string& raw) { + skipWs(); + if (eof()) return false; + std::size_t start = pos_; + char c = s_[pos_]; + if (c == '"') { + std::string tmp; + if (!parseString(tmp)) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + ++pos_; + int depth = 1; + while (!eof() && depth > 0) { + char d = s_[pos_]; + if (d == '"') { + std::string tmp; + if (!parseString(tmp)) return false; // advances past the string + continue; + } + if (d == open) ++depth; + else if (d == close) --depth; + ++pos_; + } + if (depth != 0) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + // bare scalar (number / true / false / null) + return parseRawScalar(raw); +} + +} // namespace reasampler::json diff --git a/src/core/json/json.h b/src/core/json/json.h new file mode 100644 index 0000000..ce1c281 --- /dev/null +++ b/src/core/json/json.h @@ -0,0 +1,149 @@ +// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2 +// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// This module owns the lexical half of the house JSON dialect: the escape-aware +// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare +// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE +// rejection), key+':' consumption, unknown-value skipping, and the emit side +// (escaping, %.17g / %d / %lld number rendering, the scoped object writer). +// The DOMAIN grammars — which keys exist, what shape each value takes, what is +// rejected at the model boundary — stay in the consumers (bank_model, bank_book, +// view_mode_model, owned_manifest, tail_control). One lexical definition means +// the five decoders can no longer drift on tolerance or escaping. +// +// Byte-compatibility contract (load-bearing): the emit helpers reproduce the +// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for +// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit), +// plain decimal for ints — so a re-serialized blob is byte-identical to what +// the pre-extraction writers produced. This was a structural dedupe, not a +// format change; persisted .rpp ext-state must not shift by a byte. + +#pragma once + +#include +#include +#include + +namespace reasampler::json { + +// --------------------------------------------------------------------------- +// emit helpers (writer side) +// --------------------------------------------------------------------------- + +// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX +// for remaining control chars, everything else verbatim (UTF-8 passes through). +void writeEscaped(std::string& out, const std::string& s); + +// Number rendering. %.17g is the shortest form that round-trips every IEEE-754 +// double exactly, so deserialize(serialize(x)) == x holds bit-for-bit. +std::string numToStr(double v); +std::string numToStr(std::int64_t v); +std::string numToStr(int v); + +// Flat homogeneous arrays: ["a","b"] / [1,2]. Empty vector -> "[]". +void writeStringArray(std::string& out, const std::vector& v); +void writeIntArray(std::string& out, const std::vector& v); + +// Scoped object writer: appends '{' on construction and '}' on destruction, with +// comma separation handled internally. Nested values are written by keyBegin() +// followed by the caller emitting the value (e.g. a nested Writer scope or an +// array). NOTE the destructor-close means an enclosing scope must END (brace +// block) before the built string is returned — see the NRVO note in the +// consumers' serialize() implementations. +class Writer { +public: + explicit Writer(std::string& out) : out_(out) { out_ += '{'; } + ~Writer() { out_ += '}'; } + + Writer(const Writer&) = delete; + Writer& operator=(const Writer&) = delete; + + // "key": — rawValue appended verbatim (numbers, bools, null, + // pre-serialized nested blobs). + void keyRaw(const char* key, const std::string& rawValue) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + out_ += rawValue; + } + // "key":"value" — value escaped. + void keyStr(const char* key, const std::string& value) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + writeEscaped(out_, value); + } + // "key": — caller writes the value immediately after. + void keyBegin(const char* key) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + } + +private: + void sep() { + if (first_) first_ = false; else out_ += ','; + } + std::string& out_; + bool first_ = true; +}; + +// --------------------------------------------------------------------------- +// Reader — the lexical cursor (parser side) +// --------------------------------------------------------------------------- +// +// Every method returns false on malformed input and never reads out of bounds. +// Only the subset the house writers emit is supported. The reader borrows the +// input string — it must outlive the Reader. +class Reader { +public: + explicit Reader(const std::string& s) : s_(s) {} + + bool eof() const { return pos_ >= s_.size(); } + void skipWs(); + + // Consumes `c` (after whitespace). False without advancing past `c` if the + // next non-ws char differs. + bool consume(char c); + + // JSON string literal (escapes + \uXXXX incl. surrogate pairs -> UTF-8). + bool parseString(std::string& out); + + // Bare token (number / true / false / null) up to the next structural char. + bool parseRawScalar(std::string& out); + + // Numbers: full-token parse; trailing bytes or ERANGE reject. parseInt + // additionally rejects values outside [INT_MIN, INT_MAX]. + bool parseDouble(double& out); + bool parseInt64(std::int64_t& out); + bool parseInt(int& out); + + bool parseBool(bool& out); + + // Peeks for the `null` token; consumes it if present (wasNull=true), + // otherwise leaves the position untouched (wasNull=false). Returns false + // only on eof. + bool expectNullOr(bool& wasNull); + + // An object member key + ':'. + bool parseKey(std::string& key); + + // Homogeneous arrays. Appends to `out`; empty array is valid. + bool parseStringArray(std::vector& out); + bool parseIntArray(std::vector& out); + + // Skips one value of any shape (string / object / array / bare scalar) — + // forward-compat for unknown keys. + bool skipValue(); + + // Captures the raw source text of one value verbatim (string-aware brace + // matching), so a nested blob can be handed to its own parser — the + // bank_book -> BankIndex::deserialize seam. + bool captureValue(std::string& raw); + +private: + const std::string& s_; + std::size_t pos_ = 0; +}; + +} // namespace reasampler::json diff --git a/src/core/util/file_bytes.cpp b/src/core/util/file_bytes.cpp new file mode 100644 index 0000000..09962e2 --- /dev/null +++ b/src/core/util/file_bytes.cpp @@ -0,0 +1,21 @@ +// core/util/file_bytes implementation — see file_bytes.h. + +#include "core/util/file_bytes.h" + +#include + +namespace reasampler { + +std::vector readFileBytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + const std::streamoff size = f.tellg(); + if (size <= 0) return {}; + std::vector bytes(static_cast(size)); + f.seekg(0); + f.read(reinterpret_cast(bytes.data()), size); + if (!f) return {}; + return bytes; +} + +} // namespace reasampler diff --git a/src/core/util/file_bytes.h b/src/core/util/file_bytes.h new file mode 100644 index 0000000..b1ee4bf --- /dev/null +++ b/src/core/util/file_bytes.h @@ -0,0 +1,19 @@ +// core/util/file_bytes — the ONE whole-file byte loader (Q-W1; audit T2-03). +// Pure standard library — NO REAPER, NO SWELL, NO VST3 — but it does blocking +// file I/O: NEVER call it on the audio thread (off-thread only, the same rule +// every prior hand-rolled copy carried). Linked by both artifacts. + +#pragma once + +#include +#include +#include + +namespace reasampler { + +// Reads the whole file at `path` into a byte buffer. Empty on ANY failure — +// unopenable, empty file, or short read — so the caller has exactly one +// "nothing to work with" branch. +std::vector readFileBytes(const std::string& path); + +} // namespace reasampler diff --git a/src/core/wire/wire.cpp b/src/core/wire/wire.cpp new file mode 100644 index 0000000..80c08b4 --- /dev/null +++ b/src/core/wire/wire.cpp @@ -0,0 +1,134 @@ +// core/wire implementation — see wire.h. The bodies are the hardened +// assignment_request / sample_usage / provenance (post Q-W0 T2-01a backport) +// cursor, unified; any behavioral change here changes every ext-state wire +// seam at once. + +#include "core/wire/wire.h" + +#include +#include + +namespace reasampler::wire { + +void putField(std::string& out, const std::string& field) { + out += std::to_string(field.size()); + out += ':'; + out += field; +} + +bool parseUnsignedDecimal(const std::string& s, std::int64_t& out) { + if (s.empty()) return false; + std::int64_t value = 0; + constexpr std::int64_t kMax = std::numeric_limits::max(); + for (const char c : s) { + if (c < '0' || c > '9') return false; // any non-digit -> reject whole + const int digit = c - '0'; + // Guard value*10 + digit against overflow before performing it. + if (value > (kMax - digit) / 10) return false; + value = value * 10 + digit; + } + out = value; + return true; +} + +bool Cursor::literal(const char* lit) { + if (!ok_) return false; + std::size_t i = 0; + for (; lit[i] != '\0'; ++i) { + if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); + } + pos_ += i; + return true; +} + +bool Cursor::field(std::string& out) { + if (!ok_) return false; + const std::size_t colon = s_.find(':', pos_); + if (colon == std::string::npos) return fail(); + if (colon == pos_) return fail(); // empty length token + // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. + if (colon - pos_ > 20u) return fail(); + std::size_t len = 0; + for (std::size_t i = pos_; i < colon; ++i) { + const char c = s_[i]; + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; + } + const std::size_t start = colon + 1; + // Subtraction-first form: start + len cannot wrap on a huge len. + if (start > s_.size() || len > s_.size() - start) return fail(); + out.assign(s_, start, len); + pos_ = start + len; + return true; +} + +bool Cursor::fieldInt64(std::int64_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty()) return fail(); + std::size_t i = 0; + bool neg = false; + if (f[0] == '-') { + neg = true; + i = 1; + if (f.size() == 1) return fail(); // bare "-" + } + // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit + // positive value would overflow INT64_MAX; a 20-digit negative might be valid + // (INT64_MIN) but is conservatively rejected too — see header. + if (f.size() - i > 19u) return fail(); + std::int64_t v = 0; + for (; i < f.size(); ++i) { + const char c = f[i]; + if (c < '0' || c > '9') return fail(); + const std::int64_t digit = static_cast(c - '0'); + // Overflow guard: v * 10 + digit must not exceed INT64_MAX. + if (v > (std::numeric_limits::max() - digit) / 10) + return fail(); + v = v * 10 + digit; + } + out = neg ? -v : v; + return true; +} + +bool Cursor::fieldInt(int& out) { + std::int64_t v = 0; + if (!fieldInt64(v)) return false; + if (v < std::numeric_limits::min() || v > std::numeric_limits::max()) + return fail(); + out = static_cast(v); + return true; +} + +bool Cursor::fieldSizeT(std::size_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty() || f.size() > 20u) return fail(); + std::size_t v = 0; + for (const char c : f) { + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + if (v > (std::numeric_limits::max() - digit) / 10u) + return fail(); + v = v * 10u + digit; + } + out = v; + return true; +} + +bool Cursor::fieldDouble(double& out) { + std::string f; + if (!field(f)) return false; + const char* b = f.c_str(); + char* end = nullptr; + double v = std::strtod(b, &end); + if (end != b + f.size()) return fail(); + out = v; + return true; +} + +} // namespace reasampler::wire diff --git a/src/core/wire/wire.h b/src/core/wire/wire.h new file mode 100644 index 0000000..9cdde10 --- /dev/null +++ b/src/core/wire/wire.h @@ -0,0 +1,88 @@ +// core/wire — the ONE length-prefixed ext-state wire codec (Q-W1; audit +// T2-01(b)). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// The `':'` field grammar ("one grammar across every +// ext-state seam") was previously implemented as three near-identical +// putField + Cursor copies (provenance / assignment_request / sample_usage) +// plus a fourth guarded decimal accumulate (bank_sync::parseBankGeneration) — +// and the copies drifted on the hardening. This is the single survivor, +// carrying the FULL hardening everywhere: +// - length digit-run capped at 20 (SIZE_MAX's decimal width) so a crafted +// digit run cannot accumulate past SIZE_MAX via repeated multiply; +// - overflow guard on every accumulate (multiply+add checked BEFORE applied); +// - subtraction-first bounds check so a huge len cannot wrap `start + len`; +// - fieldInt/fieldInt64 parse sign+digits manually with an INT64 overflow +// guard and an int range check — an out-of-range field FAILS the parse +// (closing the strtol errno/range gap the provenance copy carried). +// +// Wire formats on disk / ext-state are FROZEN: encode is byte-identical to the +// pre-collapse writers (std::to_string length + ':' + bytes), decode is +// tolerant-identical for every value a house writer can emit. "Never UB, never +// a partial value" is the parse-integrity promise. + +#pragma once + +#include +#include +#include + +namespace reasampler::wire { + +// Append one length-prefixed field: ':' +void putField(std::string& out, const std::string& field); + +// Whole-string, non-negative decimal parse WITHOUT exceptions or locale +// surprises (the bank_sync generation-stamp core). False on empty, any +// non-digit (incl. a leading '+'/'-'), or overflow past INT64_MAX; the +// accumulate is overflow-guarded so a pathologically long digit run can never +// wrap into a bogus small value. +bool parseUnsignedDecimal(const std::string& s, std::int64_t& out); + +// Bounds-checked cursor over an encoded string. All reads are bounds-checked; +// any short read fails the whole parse (ok_ latches false — every subsequent +// read also fails, so a caller may check ok() once at the end). +class Cursor { +public: + explicit Cursor(const std::string& s) : s_(s) {} + + bool ok() const { return ok_; } + bool atEnd() const { return pos_ >= s_.size(); } + + // Consumes an exact literal at the cursor (the magic tag). Fails if absent. + bool literal(const char* lit); + + // Reads one length-prefixed field into `out`. Fails on a missing ':', an + // empty or non-numeric length, a length that would overflow SIZE_MAX, or a + // length that runs past the end. + bool field(std::string& out); + + // Length-prefixed signed 64-bit decimal (optional leading '-'). Digit run + // capped at 19 (INT64_MAX's decimal width); overflow fails the parse. A + // 20-digit negative (only INT64_MIN itself) is conservatively rejected — + // house writers emit generation timestamps and small enums, never that. + bool fieldInt64(std::int64_t& out); + + // fieldInt64 narrowed to int; a value outside [INT_MIN, INT_MAX] FAILS the + // parse (the fixed form of the provenance copy's silent strtol narrowing). + bool fieldInt(int& out); + + // Length-prefixed unsigned decimal (element counts). Digit run capped at + // 20; overflow-guarded accumulate. Callers still apply their own + // count-vs-wire-size sanity bound BEFORE any reserve() on the result. + bool fieldSizeT(std::size_t& out); + + // Length-prefixed %.17g double. Full-token strtod; trailing bytes fail. + // Deliberately NO errno/ERANGE rejection: the writers emit %.17g of live + // doubles (incl. "inf"), and those must decode back — same accept set as + // every prior copy. + bool fieldDouble(double& out); + +private: + bool fail() { ok_ = false; return false; } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace reasampler::wire diff --git a/src/ingest.cpp b/src/ingest.cpp index 9dd4395..376a8f5 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -22,6 +22,7 @@ #include "bank_model.h" // Sample, AddResult, findByHash #include "bank_panel.h" // bankPanelRefresh #include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) #include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "persist.h" // ReaSamplerSession @@ -81,19 +82,8 @@ std::string currentProjectDir() { return projectDirOfRpp(std::string(buf.data())); } -// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror -// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy. -std::vector readFileBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamsize n = f.tellg(); - if (n <= 0) return {}; - std::vector bytes(static_cast(n)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), n); - if (!f) return {}; - return bytes; -} +// Whole-file reads (source read + bank-copy validate/hash) go through the shared +// core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable). // Writes a byte buffer to a file. Returns true on success. The caller is responsible for // ensuring the directory exists before calling. diff --git a/src/owned_manifest.cpp b/src/owned_manifest.cpp index 08c4798..ba21411 100644 --- a/src/owned_manifest.cpp +++ b/src/owned_manifest.cpp @@ -1,19 +1,17 @@ #include "owned_manifest.h" #include -#include + +#include "core/json/json.h" // owned_manifest implementation. // -// JSON is hand-rolled and self-contained (project convention: the pure core is -// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book / -// tail_control). The shape is a single object with one string array: +// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model / +// bank_book / tail_control). The shape is a single object with one string array: // // {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} // -// so a compact writer + a focused string-array parser is all it needs — far smaller -// than bank_model's full recursive-descent parser, because there is exactly one key -// and one value kind. +// so a compact writer + a focused string-array domain parse is all it needs. namespace reasampler { @@ -56,237 +54,37 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const { } // --------------------------------------------------------------------------- -// JSON writer +// JSON writer (shared core/json escape — byte-identical to the prior local one) // --------------------------------------------------------------------------- -namespace { - -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", - static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -} // namespace - std::string OwnedFileManifest::serialize() const { std::string out = "{\"owned\":["; for (std::size_t i = 0; i < paths_.size(); ++i) { if (i) out += ','; - writeEscaped(out, paths_[i]); + json::writeEscaped(out, paths_[i]); } out += "]}"; return out; } // --------------------------------------------------------------------------- -// JSON parser (string-array only) +// JSON parser (string-array-only DOMAIN grammar over the shared core/json +// lexical layer). Tolerates unknown keys (forward-compat) and requires the +// "owned" value to be an array of strings. // --------------------------------------------------------------------------- namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - // Parse the manifest object into `out`. Tolerates unknown keys (forward-compat) - // and requires the "owned" value to be an array of strings. - bool parseManifest(OwnedFileManifest& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseStringArray(std::vector& out); - bool skipValue(); // for forward-compat unknown keys -}; - -// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX for -// control chars). Positioned before the opening quote (skips leading whitespace). -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - - unsigned int hi = 0; - if (!readHex4(hi)) return false; - - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate - } - - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated string -} - -bool Parser::parseStringArray(std::vector& out) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty array - for (;;) { - std::string s; - if (!parseString(s)) return false; - out.push_back(std::move(s)); - skipWs(); - if (consume(',')) continue; - if (consume(']')) return true; - return false; // neither separator nor terminator — malformed - } -} - -// Skip a single JSON value (string / array / object / bare scalar) so an unknown key -// does not abort the parse. Minimal: enough for forward-compat siblings we don't know. -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - return parseString(tmp); - } - if (c == '[' || c == '{') { - // Balance nested brackets of either kind, ignoring bracket chars inside - // strings. Enough to step over an unknown nested value; not a full validator. - int depth = 0; - bool inStr = false; - while (!eof()) { - char d = s_[pos_]; - if (inStr) { - if (d == '\\') { pos_ += 2; continue; } - if (d == '"') inStr = false; - ++pos_; - continue; - } - if (d == '"') { inStr = true; ++pos_; continue; } - if (d == '[' || d == '{') ++depth; - else if (d == ']' || d == '}') { - --depth; - if (depth == 0) { ++pos_; return true; } - } - ++pos_; - } - return false; - } - // bare scalar (number / true / false / null) — read to the next structural char - while (!eof()) { - char d = s_[pos_]; - if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || - d == '\n' || d == '\r') - break; - ++pos_; - } - return true; -} - -bool Parser::parseManifest(OwnedFileManifest& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object -> empty manifest +bool parseManifest(json::Reader& r, OwnedFileManifest& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object -> empty manifest for (;;) { std::string key; - if (!parseString(key)) return false; - if (!consume(':')) return false; + if (!r.parseKey(key)) return false; if (key == "owned") { std::vector paths; - if (!parseStringArray(paths)) return false; + if (!r.parseStringArray(paths)) return false; for (auto& p : paths) { // Feed through add() so the persisted invariants (dedup, reject // empty/absolute) are re-asserted on load — a hand-edited or corrupt @@ -294,21 +92,21 @@ bool Parser::parseManifest(OwnedFileManifest& out) { out.add(p); } } else { - if (!skipValue()) return false; // forward-compat: tolerate unknown keys + if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys } - skipWs(); - if (consume(',')) continue; - if (consume('}')) return true; + r.skipWs(); + if (r.consume(',')) continue; + if (r.consume('}')) return true; return false; } } } // namespace -std::optional OwnedFileManifest::deserialize(const std::string& json) { +std::optional OwnedFileManifest::deserialize(const std::string& blob) { OwnedFileManifest m; - Parser p(json); - if (!p.parseManifest(m)) return std::nullopt; + json::Reader r(blob); + if (!parseManifest(r, m)) return std::nullopt; return m; } diff --git a/src/provenance.cpp b/src/provenance.cpp index 89cd816..712394f 100644 --- a/src/provenance.cpp +++ b/src/provenance.cpp @@ -1,8 +1,8 @@ #include "provenance.h" #include -#include -#include + +#include "core/wire/wire.h" // provenance implementation — pure, self-contained (no third-party lib, mirror of // bank_model's hand-rolled encoding discipline). @@ -39,12 +39,12 @@ namespace { constexpr const char* kMagic = "rsprov1"; -// Append one length-prefixed field: ':' -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} +// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full +// hardening (incl. the fixed fieldInt range check that closes the old strtol +// silent-narrowing TODO). Only the %.17g double rendering stays local — it is +// this writer's convention, shared with the bank model's JSON doubles. +using wire::putField; +using Cursor = wire::Cursor; std::string dblToStr(double v) { char buf[32]; @@ -52,111 +52,6 @@ std::string dblToStr(double v) { return buf; } -// Cursor over the encoded string. All reads are bounds-checked; any short read -// fails the whole parse (ok_ latches false). -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or - // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past - // the end. Hardened form backported from the assignment_request / sample_usage - // siblings (Q-W0 T2-01a): the digit count is capped at 20 (the decimal width of - // SIZE_MAX on a 64-bit host) so a crafted 200-digit length cannot accumulate past - // SIZE_MAX via repeated multiply, and the bounds check is subtraction-first so a - // huge `len` cannot wrap `start + len` past the end test. - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. - if (colon - pos_ > 20u) return fail(); - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - // Subtraction-first form: start + len cannot wrap on a huge len. - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - bool fieldInt(int& out) { - std::string f; - if (!field(f)) return false; - return toInt(f, out); - } - - // A length-prefixed unsigned decimal (the GUID count). Hardened (Q-W0 T2-01a, the - // sample_usage fieldCount pattern): fails on empty, non-digit, a digit run past 20 - // (SIZE_MAX's decimal width), or an accumulate that would overflow SIZE_MAX. - bool fieldSizeT(std::size_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty() || f.size() > 20u) return fail(); - std::size_t v = 0; - for (const char c : f) { - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - if (v > (std::numeric_limits::max() - digit) / 10u) - return fail(); - v = v * 10u + digit; - } - out = v; - return true; - } - - bool fieldDouble(double& out) { - std::string f; - if (!field(f)) return false; - const char* b = f.c_str(); - char* end = nullptr; - double v = std::strtod(b, &end); - if (end != b + f.size()) return fail(); - out = v; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - const std::string l(lit); - if (s_.compare(pos_, l.size(), l) != 0) return fail(); - pos_ += l.size(); - return true; - } - -private: - bool fail() { ok_ = false; return false; } - // TODO(Q-W1): strtol does not check errno/range here, so an out-of-range field narrows - // silently to LONG_MAX (then truncates into `int`) instead of failing parse. Flagged for - // the Q-W1 wire-codec collapse rather than fixed in place. - static bool toInt(const std::string& f, int& out) { - const char* b = f.c_str(); - char* end = nullptr; - long v = std::strtol(b, &end, 10); - if (end != b + f.size() || f.empty()) return false; - out = static_cast(v); - return true; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; - } // namespace std::string fxChainIdentity(const std::vector& entries) { diff --git a/src/sample_usage.cpp b/src/sample_usage.cpp index 8414564..88d64f0 100644 --- a/src/sample_usage.cpp +++ b/src/sample_usage.cpp @@ -3,8 +3,8 @@ #include "sample_usage.h" #include -#include -#include + +#include "core/wire/wire.h" namespace reasampler { @@ -12,81 +12,13 @@ namespace { constexpr const char* kMagic = "rsusage1"; -// Append one length-prefixed field: ':' . The same wire idiom as -// assignment_request / provenance — one grammar across every ext-state seam. -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} - -// Bounds-checked cursor over the encoded string (the assignment_request Cursor, trimmed -// to the two field kinds this record needs). A short read latches ok_ false. -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - if (colon - pos_ > 20u) return fail(); // SIZE_MAX is 20 decimal digits - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - std::size_t i = 0; - for (; lit[i] != '\0'; ++i) { - if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); - } - pos_ += i; - return true; - } - - // A length-prefixed unsigned decimal (the hold count). Fails on empty, non-digit, - // or a value past a sane ceiling (a record cannot hold more entries than bytes). - bool fieldCount(std::size_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty() || f.size() > 10u) return fail(); - std::size_t v = 0; - for (const char c : f) { - if (c < '0' || c > '9') return fail(); - v = v * 10u + static_cast(c - '0'); - } - out = v; - return true; - } - -private: - bool fail() { - ok_ = false; - return false; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; +// The shared core/wire codec (Q-W1, T2-01b) — one grammar across every +// ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the +// codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count +// the old cap accepted decodes identically, and any larger count is rejected by +// the count-vs-wire-size sanity bound at the call site below. +using wire::putField; +using Cursor = wire::Cursor; } // namespace @@ -115,7 +47,7 @@ std::optional decodeUsageRecord(const std::string& wire) { else if (unionedField == "0") rec.unioned = false; else return std::nullopt; // anything else is corruption -> reject whole std::size_t count = 0; - if (!c.fieldCount(count)) return std::nullopt; + if (!c.fieldSizeT(count)) return std::nullopt; // Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is // provably bogus — reject before looping rather than iterating a crafted huge count. if (count > wire.size() / 4u + 1u) return std::nullopt; diff --git a/src/tail_control.cpp b/src/tail_control.cpp index 1d4063a..2683e2e 100644 --- a/src/tail_control.cpp +++ b/src/tail_control.cpp @@ -3,10 +3,9 @@ #include "tail_control.h" #include -#include #include -#include -#include + +#include "core/json/json.h" namespace reasampler { @@ -51,13 +50,13 @@ std::string tailToggleLabel(const TailSetting& setting) { // JSON round-trip // --------------------------------------------------------------------------- // -// The setting is a flat object of one enum + one double, so a compact hand-rolled -// writer + a tolerant minimal reader is the simplest thing that works (mirroring -// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant -// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly — -// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately -// forgiving: any parse failure returns nullopt so the caller falls back to a default, -// exactly as an absent ext-state key does. +// The setting is a flat object of one enum + one double, riding the shared +// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader — +// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17 +// significant digits (%.17g) — the shortest form that round-trips every IEEE-754 +// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit. +// deserialize stays forgiving in outcome: any parse failure returns nullopt so +// the caller falls back to a default, exactly as an absent ext-state key does. namespace { @@ -81,48 +80,48 @@ std::optional modeFromInt(int v) { } } -// Find the value token following `"key":` in `json`. Returns a pointer just past the -// colon (skipping whitespace) or nullptr if the key is absent. Minimal: the writer -// emits exactly one flat object with unique keys, so a substring search is sufficient -// and there is no nesting to confuse it. -const char* valueAfterKey(const std::string& json, const char* key) { - const std::string needle = std::string("\"") + key + "\""; - const std::size_t pos = json.find(needle); - if (pos == std::string::npos) return nullptr; - const char* p = json.c_str() + pos + needle.size(); - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; - if (*p != ':') return nullptr; - ++p; - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; - return p; -} - } // namespace std::string serializeTailSetting(const TailSetting& setting) { - char buf[128]; - std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}", - modeToInt(setting.mode), setting.manualMs); - return std::string(buf); + // Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}. + std::string out; + { + json::Writer w(out); + w.keyRaw("mode", json::numToStr(modeToInt(setting.mode))); + w.keyRaw("manualMs", json::numToStr(setting.manualMs)); + } // Writer closes the object here (see bank_model's NRVO note) + return out; } -std::optional deserializeTailSetting(const std::string& json) { - const char* modeTok = valueAfterKey(json, "mode"); - const char* msTok = valueAfterKey(json, "manualMs"); - if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default +std::optional deserializeTailSetting(const std::string& blob) { + json::Reader r(blob); + if (!r.consume('{')) return std::nullopt; - char* end = nullptr; - errno = 0; - const long modeVal = std::strtol(modeTok, &end, 10); - if (end == modeTok || errno != 0) return std::nullopt; - const std::optional mode = modeFromInt(static_cast(modeVal)); + int modeInt = 0; + double ms = 0.0; + bool haveMode = false, haveMs = false; + r.skipWs(); + if (!r.consume('}')) { + do { + std::string key; + if (!r.parseKey(key)) return std::nullopt; + if (key == "mode") { + if (!r.parseInt(modeInt)) return std::nullopt; + haveMode = true; + } else if (key == "manualMs") { + if (!r.parseDouble(ms)) return std::nullopt; + haveMs = 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 + + const std::optional mode = modeFromInt(modeInt); if (!mode) return std::nullopt; - end = nullptr; - errno = 0; - const double ms = std::strtod(msTok, &end); - if (end == msTok || errno != 0) return std::nullopt; - TailSetting out; out.mode = *mode; out.manualMs = ms; diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 9b6af0b..c215e56 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -2,19 +2,16 @@ #include #include -#include -#include -#include -#include #include #include +#include "core/json/json.h" #include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention // view_mode_model implementation. // -// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief: -// keep the pure core dependency-free — no third-party JSON lib). A compact writer +// JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model. +// A compact writer // plus a recursive-descent parser covers the field set: the mode registry, the // GUID-keyed membership map, per-track snapshots (with a variable-length per-FX // offline vector), and the active mode. Ints are emitted plainly; strings are @@ -493,73 +490,12 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string intToStr(int v) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%d", v); - return buf; -} - -void writeIntArray(std::string& out, const std::vector& v) { - out += '['; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i) out += ','; - out += intToStr(v[i]); - } - out += ']'; -} - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { if (first_) first_ = false; else out_ += ','; } - std::string& out_; - bool first_ = true; -}; +// Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the +// prior file-local writer, so the emitted blob is byte-identical. +using json::writeEscaped; +using json::writeIntArray; +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; } // namespace @@ -660,250 +596,64 @@ std::string ViewModeModel::serialize() const { namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - bool parseModel(ViewModeModel& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - bool parseString(std::string& out); - bool parseRawScalar(std::string& out); - bool parseInt(int& out); - bool parseBool(bool& out); - bool parseKey(std::string& key); - bool skipValue(); - - bool parseModes(ModeRegistry& reg); - bool parseMembership(MembershipIndex& idx); - bool parseSnapshots(std::map& snaps); - bool parseLanes(LaneOwnershipIndex& idx); - bool parseIntArray(std::vector& out); -}; - -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - unsigned int hi = 0; - if (!readHex4(hi)) return false; - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; - } - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated -} - -bool Parser::parseRawScalar(std::string& out) { - skipWs(); - std::size_t start = pos_; - while (!eof()) { - char c = s_[pos_]; - if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || - c == '\n' || c == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - out.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseInt(int& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - long long v = std::strtoll(b, &end, 10); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; - if (v < INT_MIN || v > INT_MAX) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseBool(bool& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - if (tok == "true") { out = true; return true; } - if (tok == "false") { out = false; return true; } - return false; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - return consume(':'); -} - -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { std::string tmp; return parseString(tmp); } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; continue; } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - return depth == 0; - } - std::string tmp; - return parseRawScalar(tmp); -} - -bool Parser::parseIntArray(std::vector& out) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; - do { - int v = 0; - if (!parseInt(v)) return false; - out.push_back(v); - } while (consume(',')); - return consume(']'); -} +// The model DOMAIN grammar over the shared core/json lexical layer (Q-W1). // The registry starts seeded (Arrange + Design). Deserialization must reproduce the // serialized set exactly, so we replace the seeded contents with the parsed ones — // add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as // duplicates and the ordinals/names would not round-trip. We therefore parse into a // fresh vector and swap. `reg` is passed empty (see parseModel). -bool Parser::parseModes(ModeRegistry& reg) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty array (unusual, but valid) +bool parseModes(json::Reader& r, ModeRegistry& reg) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty array (unusual, but valid) do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; Mode m; bool haveId = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "id") { if (!parseString(m.id)) return false; haveId = true; } - else if (k == "displayName") { if (!parseString(m.displayName)) return false; } - else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; } + else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; } + else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveId || !reg.add(m)) return false; // malformed / duplicate id - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseMembership(MembershipIndex& idx) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseMembership(json::Reader& r, MembershipIndex& idx) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string guid; Membership mem; bool haveGuid = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } + if (!r.parseKey(k)) return false; + if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; } else if (k == "modes") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { std::string id; - if (!parseString(id)) return false; + if (!r.parseString(id)) return false; mem.modeIds.insert(id); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } - else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; // Install the entry verbatim (tag() would clear a multi-mode set and drop // show-both). A serialized entry is trusted to already satisfy the model's @@ -918,55 +668,55 @@ bool Parser::parseMembership(MembershipIndex& idx) { // mode that no longer exists has an immediate behavioral consequence, so it // is caught and the parse is rejected. if (!idx.restore(guid, mem)) return false; - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseSnapshots(std::map& snaps) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseSnapshots(json::Reader& r, std::map& snaps) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string guid; TrackSnapshot snap; bool haveGuid = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } - else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; } - else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; } - else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; } - else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; } - else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; } + else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; } + else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; } + else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; } + else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; } + else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; snaps[guid] = snap; - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseLanes(LaneOwnershipIndex& idx) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string trackGuid, laneKey, mode; bool haveTrack = false, haveLane = false, managed = false, haveManaged = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; } - else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; } - else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; } - else if (k == "mode") { if (!parseString(mode)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; } + else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; } + else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; } + else if (k == "mode") { if (!r.parseString(mode)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; // Both keys mandatory and non-empty (they form the lane's identity). A managed // lane must carry a non-empty mode; a manual lane must not claim one. Enforcing // this on parse keeps a round-tripped index byte-for-byte identical to the @@ -980,14 +730,14 @@ bool Parser::parseLanes(LaneOwnershipIndex& idx) { if (!mode.empty()) return false; // manual lane must not carry a mode if (!idx.setManual(trackGuid, laneKey)) return false; } - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseModel(ViewModeModel& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // lenient empty root ⇒ default-seeded model +bool parseModel(json::Reader& r, ViewModeModel& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // lenient empty root ⇒ default-seeded model ModeRegistry reg; // seeded default; REPLACED if a modes array is present bool haveModes = false; @@ -999,33 +749,33 @@ bool Parser::parseModel(ViewModeModel& out) { do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "activeMode") { - if (!parseString(activeMode)) return false; + if (!r.parseString(activeMode)) return false; haveActive = true; } else if (key == "modes") { ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own - if (!parseModes(fresh)) return false; + if (!parseModes(r, fresh)) return false; reg = fresh; haveModes = true; } else if (key == "membership") { - if (!parseMembership(membership)) return false; + if (!parseMembership(r, membership)) return false; } else if (key == "snapshots") { - if (!parseSnapshots(snaps)) return false; + if (!parseSnapshots(r, snaps)) return false; } else if (key == "lanes") { - if (!parseLanes(lanes)) return false; + if (!parseLanes(r, lanes)) return false; } else { // Unknown keys and the "version" field are skipped here. // "version" is serialized as a forward-compat placeholder — there is no // active version gate yet; all persisted data is parsed the same way // regardless of the value. A future gate would add a version branch here. - if (!skipValue()) return false; + if (!r.skipValue()) return false; } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; - skipWs(); - if (!eof()) return false; // trailing garbage + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage if (haveModes) out.modes() = reg; out.membership() = membership; @@ -1039,10 +789,10 @@ bool Parser::parseModel(ViewModeModel& out) { } // namespace -std::optional ViewModeModel::deserialize(const std::string& json) { +std::optional ViewModeModel::deserialize(const std::string& blob) { ViewModeModel vm; - Parser p(json); - if (!p.parseModel(vm)) return std::nullopt; + json::Reader r(blob); + if (!parseModel(r, vm)) return std::nullopt; return vm; } diff --git a/src/vst/bank_sync.cpp b/src/vst/bank_sync.cpp index 8ca894b..aff6c6b 100644 --- a/src/vst/bank_sync.cpp +++ b/src/vst/bank_sync.cpp @@ -3,27 +3,20 @@ #include "bank_sync.h" #include -#include #include +#include "core/wire/wire.h" + namespace reasampler::vst { std::int64_t parseBankGeneration(const std::string& raw) { - if (raw.empty()) return kBankGenerationAbsent; - - // Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises. - // A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max - // all reject to the absent default (0). Manual accumulation with an overflow guard so a + // Whole-string, non-negative decimal parse WITHOUT exceptions or locale + // surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading + // '+' / '-', any non-digit, an empty string, or overflow past int64 max all + // reject to the absent default (0); the guarded accumulate means a // pathologically long digit run can never wrap into a bogus small value. std::int64_t value = 0; - constexpr std::int64_t kMax = std::numeric_limits::max(); - for (const char c : raw) { - if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole - const int digit = c - '0'; - // Guard value*10 + digit against overflow before performing it. - if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject - value = value * 10 + digit; - } + if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent; return value; } diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 13f8a6c..1ec3362 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -15,6 +15,7 @@ #include "capture_browser.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) #include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) #include "editor_geometry.h" // Rect, contains @@ -770,16 +771,8 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& if (!relativePath.empty()) { const std::string projectDir = processor_->bridge().activeProjectDir(); const std::string abs = resolveBankFile(projectDir, relativePath); - std::vector bytes; - std::ifstream f(abs, std::ios::binary | std::ios::ate); - if (f) { - const std::streamoff size = f.tellg(); - if (size > 0) { - f.seekg(0, std::ios::beg); - bytes.resize(static_cast(size)); - if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); - } - } + // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. + const std::vector bytes = readFileBytes(abs); const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { std::vector interleaved = diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index eb8f2d4..3cb03e2 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -20,6 +20,7 @@ #include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) #include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) #include "reasampler_editor.h" @@ -69,19 +70,9 @@ std::string mintUsageInstanceGuid() { return std::string(buf); } -// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on -// any failure — the caller treats an unreadable WAV as "nothing to play". -std::vector readFileBytes(const std::string& path) { - std::vector bytes; - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return bytes; - const std::streamoff size = f.tellg(); - if (size <= 0) return bytes; - f.seekg(0, std::ios::beg); - bytes.resize(static_cast(size)); - if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). +// Off-thread only (blocking file I/O). Empty on any failure — the caller treats +// an unreadable WAV as "nothing to play". // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file // I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode diff --git a/tests/test_file_bytes.cpp b/tests/test_file_bytes.cpp new file mode 100644 index 0000000..d0bc358 --- /dev/null +++ b/tests/test_file_bytes.cpp @@ -0,0 +1,51 @@ +// Standalone tests for reasampler::readFileBytes — no REAPER, no framework. +// The ONE whole-file loader (Q-W1, T2-03) shared by both artifacts. Exercises +// the three-way contract: exact bytes back, empty on a missing file, empty on +// an empty file. Uses a scratch file in the test's working directory. + +#include "../src/core/util/file_bytes.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kScratch = "file_bytes_scratch.bin"; + +static void testReadsExactBytesBack() { + // Binary content incl. NUL and 0xFF — the loader must be byte-transparent. + const std::vector payload = {0x00, 0x01, 0xFF, 0x7E, 0x00, 0x0A}; + { + std::ofstream f(kScratch, std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(payload.data()), + static_cast(payload.size())); + } + CHECK(readFileBytes(kScratch) == payload); + std::remove(kScratch); +} + +static void testMissingFileIsEmpty() { + CHECK(readFileBytes("no_such_file_anywhere.bin").empty()); +} + +static void testEmptyFileIsEmpty() { + { std::ofstream f(kScratch, std::ios::binary | std::ios::trunc); } + CHECK(readFileBytes(kScratch).empty()); + std::remove(kScratch); +} + +int main() { + testReadsExactBytesBack(); + testMissingFileIsEmpty(); + testEmptyFileIsEmpty(); + + if (g_fail == 0) std::printf("file_bytes: all tests passed\n"); + else std::printf("file_bytes: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_json.cpp b/tests/test_json.cpp new file mode 100644 index 0000000..687042c --- /dev/null +++ b/tests/test_json.cpp @@ -0,0 +1,296 @@ +// Standalone tests for reasampler::json — no REAPER, no framework. The ONE +// lexical JSON layer (Q-W1) behind bank_model / bank_book / view_mode_model / +// owned_manifest / tail_control. The consumers' own suites prove the domain +// grammars; this suite pins the LEXICAL contract — the escape set, the number +// renderings (byte-exact), the parse tolerances, and the reject paths — so a +// change here is caught before it silently shifts five persisted-blob formats. +// +// NOTE: json::Reader BORROWS its input string, so every test binds a named +// std::string first — never a temporary. + +#include "../src/core/json/json.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Convenience: parse helpers over a named buffer per call site. +static bool intFrom(const std::string& s, int& v) { json::Reader r(s); return r.parseInt(v); } +static bool int64From(const std::string& s, std::int64_t& v) { json::Reader r(s); return r.parseInt64(v); } +static bool doubleFrom(const std::string& s, double& v) { json::Reader r(s); return r.parseDouble(v); } +static bool boolFrom(const std::string& s, bool& v) { json::Reader r(s); return r.parseBool(v); } +static bool stringFrom(const std::string& s, std::string& v) { json::Reader r(s); return r.parseString(v); } + +// --- emit: writeEscaped ------------------------------------------------------- + +static void testEscapeExactBytes() { + // The seven short escapes + \u00XX for remaining control chars, verbatim + // pass-through otherwise. Byte-exact: this is the persisted-blob format. + std::string out; + json::writeEscaped(out, "a\"b\\c\n\t\x01z"); + CHECK(out == "\"a\\\"b\\\\c\\n\\t\\u0001z\""); +} + +static void testEscapeUtf8PassesThrough() { + // Multi-byte UTF-8 passes through verbatim; only C0 controls are \u-escaped. + std::string out; + json::writeEscaped(out, "gr\xC3\xBC n"); // "grü n" + CHECK(out == "\"gr\xC3\xBC n\""); +} + +// --- emit: numToStr ----------------------------------------------------------- + +static void testNumToStrIntForms() { + CHECK(json::numToStr(0) == "0"); + CHECK(json::numToStr(-7) == "-7"); + CHECK(json::numToStr(INT_MAX) == "2147483647"); + CHECK(json::numToStr(static_cast(1) << 40) == "1099511627776"); + CHECK(json::numToStr(2000.0) == "2000"); // %.17g drops the trailing .0 + CHECK(json::numToStr(0.5) == "0.5"); +} + +static void testDoubleRoundTripsBitForBit() { + // %.17g is the shortest form that round-trips every IEEE-754 double. + const double v = 3141.592653589793; + double back = 0.0; + CHECK(doubleFrom(json::numToStr(v), back)); + CHECK(back == v); +} + +// --- emit: Writer object grammar ---------------------------------------------- + +static void testWriterEmitsExactObjectBytes() { + std::string out; + { + json::Writer w(out); + w.keyRaw("a", json::numToStr(1)); + w.keyStr("b", "x\"y"); + w.keyBegin("c"); + { + json::Writer nested(out); + nested.keyRaw("d", json::numToStr(2.5)); + } + w.keyBegin("e"); + json::writeStringArray(out, {"p", "q"}); + w.keyBegin("f"); + json::writeIntArray(out, {1, 2}); + } + CHECK(out == "{\"a\":1,\"b\":\"x\\\"y\",\"c\":{\"d\":2.5}," + "\"e\":[\"p\",\"q\"],\"f\":[1,2]}"); +} + +static void testEmptyArraysEmitBrackets() { + std::string s, i; + json::writeStringArray(s, {}); + json::writeIntArray(i, {}); + CHECK(s == "[]"); + CHECK(i == "[]"); +} + +// --- Reader: strings ---------------------------------------------------------- + +static void testParseStringEscapes() { + std::string out; + CHECK(stringFrom(" \"a\\\"b\\\\c\\n\\u0041\"", out)); + CHECK(out == "a\"b\\c\nA"); +} + +static void testParseStringSurrogatePairToUtf8() { + // \uD83D\uDE00 (grinning face) -> F0 9F 98 80. + std::string out; + CHECK(stringFrom("\"\\ud83d\\ude00\"", out)); + CHECK(out == "\xF0\x9F\x98\x80"); +} + +static void testParseStringRejectsMalformed() { + std::string out; + CHECK(!stringFrom("\"unterminated", out)); + CHECK(!stringFrom("\"bad\\qescape\"", out)); + CHECK(!stringFrom("\"\\ud800 alone\"", out)); // unpaired high surrogate + CHECK(!stringFrom("\"\\udc00\"", out)); // unpaired low surrogate + CHECK(!stringFrom("noquote", out)); +} + +// --- Reader: numbers ---------------------------------------------------------- + +static void testParseIntAcceptsAndRejects() { + int v = 0; + CHECK(intFrom("42,", v)); CHECK(v == 42); + CHECK(intFrom("-7}", v)); CHECK(v == -7); + CHECK(intFrom("2147483647]", v)); CHECK(v == INT_MAX); + // Out of int range is REJECTED (the unified guard every consumer now shares). + CHECK(!intFrom("2147483648,", v)); + CHECK(!intFrom("1.5,", v)); + CHECK(!intFrom("x,", v)); + CHECK(!intFrom("", v)); +} + +static void testParseInt64RangeAndReject() { + std::int64_t v = 0; + CHECK(int64From("9223372036854775807,", v)); + CHECK(v == 9223372036854775807LL); + CHECK(!int64From("9223372036854775808,", v)); // ERANGE -> reject +} + +static void testParseDoubleRejectsRangeAndGarbage() { + double v = 0; + CHECK(!doubleFrom("1e999,", v)); // ERANGE + CHECK(!doubleFrom("1.5abc,", v)); // trailing bytes + CHECK(doubleFrom("2.5}", v)); CHECK(v == 2.5); +} + +static void testParseBool() { + bool v = false; + CHECK(boolFrom("true,", v)); CHECK(v); + CHECK(boolFrom("false]", v)); CHECK(!v); + CHECK(!boolFrom("TRUE,", v)); +} + +// --- Reader: structure -------------------------------------------------------- + +static void testExpectNullOr() { + { + const std::string s = "null,"; + json::Reader r(s); + bool wasNull = false; + CHECK(r.expectNullOr(wasNull)); CHECK(wasNull); CHECK(r.consume(',')); + } + { + const std::string s = "\"x\""; + json::Reader r(s); + bool wasNull = true; + std::string v; + CHECK(r.expectNullOr(wasNull)); CHECK(!wasNull); + CHECK(r.parseString(v)); CHECK(v == "x"); + } + { + const std::string s; + json::Reader r(s); + bool wasNull = false; + CHECK(!r.expectNullOr(wasNull)); + } +} + +static void testParseKeyConsumesColon() { + const std::string s = " \"k\" : 1"; + json::Reader r(s); + std::string k; + int v = 0; + CHECK(r.parseKey(k)); + CHECK(k == "k"); + CHECK(r.parseInt(v)); + CHECK(v == 1); +} + +static void testParseArraysAppend() { + { + const std::string s = "[\"a\",\"b\"]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseStringArray(v)); + CHECK(v.size() == 2 && v[0] == "a" && v[1] == "b"); + } + { + const std::string s = "[]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseStringArray(v)); CHECK(v.empty()); + } + { + const std::string s = "[1,2]"; // non-string element + json::Reader r(s); + std::vector v; + CHECK(!r.parseStringArray(v)); + } + { + const std::string s = "[1,2,3]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseIntArray(v)); + CHECK(v.size() == 3 && v[2] == 3); + } + { + const std::string s = "[1,"; // truncated + json::Reader r(s); + std::vector v; + CHECK(!r.parseIntArray(v)); + } +} + +static void testSkipValueOverNestedShapes() { + // Skips a nested object whose strings contain structural chars, then the + // cursor sits exactly on the next separator. + const std::string s = "{\"deep\":[\"}\",{\"x\":\"]\"}]},7"; + json::Reader r(s); + CHECK(r.skipValue()); + CHECK(r.consume(',')); + int v = 0; + CHECK(r.parseInt(v)); + CHECK(v == 7); +} + +static void testCaptureValueVerbatim() { + const std::string s = " {\"a\":[1,\"{\"]} ,tail"; + json::Reader r(s); + std::string raw; + CHECK(r.captureValue(raw)); + CHECK(raw == "{\"a\":[1,\"{\"]}"); + CHECK(r.consume(',')); +} + +static void testWriterOutputParsesBack() { + // The emitted object is consumable by the Reader — the seam the five + // consumers rely on (writer and reader agree on one dialect). + std::string out; + { + json::Writer w(out); + w.keyStr("name", "tab\there"); + w.keyRaw("n", json::numToStr(-3)); + } + json::Reader r(out); + CHECK(r.consume('{')); + std::string k1, v1; + CHECK(r.parseKey(k1) && k1 == "name"); + CHECK(r.parseString(v1) && v1 == "tab\there"); + CHECK(r.consume(',')); + std::string k2; + int v2 = 0; + CHECK(r.parseKey(k2) && k2 == "n"); + CHECK(r.parseInt(v2) && v2 == -3); + CHECK(r.consume('}')); + r.skipWs(); + CHECK(r.eof()); +} + +int main() { + testEscapeExactBytes(); + testEscapeUtf8PassesThrough(); + testNumToStrIntForms(); + testDoubleRoundTripsBitForBit(); + testWriterEmitsExactObjectBytes(); + testEmptyArraysEmitBrackets(); + testParseStringEscapes(); + testParseStringSurrogatePairToUtf8(); + testParseStringRejectsMalformed(); + testParseIntAcceptsAndRejects(); + testParseInt64RangeAndReject(); + testParseDoubleRejectsRangeAndGarbage(); + testParseBool(); + testExpectNullOr(); + testParseKeyConsumesColon(); + testParseArraysAppend(); + testSkipValueOverNestedShapes(); + testCaptureValueVerbatim(); + testWriterOutputParsesBack(); + + if (g_fail == 0) std::printf("json: all tests passed\n"); + else std::printf("json: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp index 09d20b0..20738ad 100644 --- a/tests/test_tail_control.cpp +++ b/tests/test_tail_control.cpp @@ -135,6 +135,20 @@ static void testRoundTripAuto() { CHECK(back && settingsEqual(*back, s)); } +static void testSerializeByteIdentity() { + // Q-W1 structural-dedupe guard: the core/json-backed writer must emit the + // EXACT bytes the former snprintf writer produced ({"mode":%d,"manualMs":%.17g}) + // and re-serializing a round-tripped setting must be byte-identical — the blob + // lives in the .rpp, so a byte shift would dirty every saved project. + TailSetting s; // None + 2000.0 default + CHECK(serializeTailSetting(s) == "{\"mode\":0,\"manualMs\":2000}"); + TailSetting man; man.mode = TailMode::Manual; man.manualMs = 3141.592653589793; + const std::string json = serializeTailSetting(man); + auto back = deserializeTailSetting(json); + CHECK(back.has_value()); + CHECK(back && serializeTailSetting(*back) == json); // stable second round-trip +} + static void testDeserializeEmptyIsDefault() { // An absent/empty stored value (older project) -> nullopt, so the caller falls // back to the default. This is the graceful-old-project path the brief requires. @@ -164,6 +178,7 @@ int main() { testRoundTripNoneDefault(); testRoundTripManualArbitraryMs(); testRoundTripAuto(); + testSerializeByteIdentity(); testDeserializeEmptyIsDefault(); testDeserializeMalformedIsDefault(); diff --git a/tests/test_wire.cpp b/tests/test_wire.cpp new file mode 100644 index 0000000..3f58399 --- /dev/null +++ b/tests/test_wire.cpp @@ -0,0 +1,190 @@ +// Standalone tests for reasampler::wire — no REAPER, no framework. The ONE +// length-prefixed ext-state wire codec (Q-W1, T2-01b) behind provenance / +// assignment_request / sample_usage / bank_sync. The consumers' own suites +// prove their record grammars round-trip; this suite pins the CODEC contract — +// byte-exact encode, the full hardening (length caps, overflow guards, +// subtraction-first bounds), and the fixed fieldInt range rejection. +// +// NOTE: wire::Cursor BORROWS its input string, so every helper takes a named / +// reference-bound std::string — the Cursor never outlives its buffer. + +#include "../src/core/wire/wire.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// One length-prefixed field around `v` — the writer-side convention. +static std::string enc(const std::string& v) { + std::string out; + wire::putField(out, v); + return out; +} + +// Single-field decode helpers (Cursor + buffer share the call's lifetime). +static bool fieldFrom(const std::string& s, std::string& out) { + wire::Cursor c(s); + return c.field(out); +} +static bool i64From(const std::string& s, std::int64_t& out) { + wire::Cursor c(s); + return c.fieldInt64(out); +} +static bool intFrom(const std::string& s, int& out) { + wire::Cursor c(s); + return c.fieldInt(out); +} +static bool sizeFrom(const std::string& s, std::size_t& out) { + wire::Cursor c(s); + return c.fieldSizeT(out); +} +static bool dblFrom(const std::string& s, double& out) { + wire::Cursor c(s); + return c.fieldDouble(out); +} + +// --- putField: byte-exact encode ---------------------------------------------- + +static void testPutFieldExactBytes() { + std::string out; + wire::putField(out, "abc"); + CHECK(out == "3:abc"); + wire::putField(out, ""); // empty field is legal: "0:" + CHECK(out == "3:abc0:"); + wire::putField(out, "a:b"); // ':' inside a value cannot shift the parse + CHECK(out == "3:abc0:3:a:b"); +} + +// --- Cursor: round-trip + literal --------------------------------------------- + +static void testFieldRoundTripIncludingSeparators() { + std::string out = "magic"; + wire::putField(out, "12:34"); // digits + colons in the value + wire::putField(out, ""); + wire::putField(out, "tail"); + wire::Cursor c(out); + std::string a, b, t; + CHECK(c.literal("magic")); + CHECK(c.field(a) && a == "12:34"); + CHECK(c.field(b) && b.empty()); + CHECK(c.field(t) && t == "tail"); + CHECK(c.ok() && c.atEnd()); +} + +static void testLiteralMismatchFails() { + const std::string good = "rsprov1x"; + const std::string wrong = "rsprov0x"; + const std::string truncated = "rspro"; + { wire::Cursor c(good); CHECK(c.literal("rsprov1")); } + { wire::Cursor c(wrong); CHECK(!c.literal("rsprov1")); CHECK(!c.ok()); } + { wire::Cursor c(truncated); CHECK(!c.literal("rsprov1")); } +} + +// --- Cursor: field hardening --------------------------------------------------- + +static void testFieldRejectsMalformedLengths() { + std::string f; + CHECK(!fieldFrom("abc", f)); // no colon + CHECK(!fieldFrom(":x", f)); // empty length + CHECK(!fieldFrom("2x:ab", f)); // non-digit length + CHECK(!fieldFrom("9:ab", f)); // runs past end + // A 200-digit length cannot accumulate past SIZE_MAX (digit-run cap). + CHECK(!fieldFrom(std::string(200, '9') + ":x", f)); + // Exactly-20-digit values: SIZE_MAX itself passes the accumulate but fails the + // bounds check; one past SIZE_MAX trips the overflow guard. + CHECK(!fieldFrom("18446744073709551615:x", f)); + CHECK(!fieldFrom("18446744073709551616:x", f)); +} + +static void testFailureLatchesOk() { + // After one failed read every subsequent read fails too — the caller may + // check ok() once at the end (the "never a partial value" discipline). + const std::string s = "3:abc"; + wire::Cursor c(s); + std::string f; + CHECK(!c.literal("nope")); + CHECK(!c.field(f)); + CHECK(!c.ok()); +} + +// --- Cursor: fieldInt64 / fieldInt -------------------------------------------- + +static void testFieldInt64AcceptsAndRejects() { + std::int64_t v = 0; + CHECK(i64From(enc("12345"), v)); CHECK(v == 12345); + CHECK(i64From(enc("-42"), v)); CHECK(v == -42); + CHECK(i64From(enc("9223372036854775807"), v)); // INT64_MAX + CHECK(v == 9223372036854775807LL); + CHECK(!i64From(enc("9223372036854775808"), v)); // overflow + CHECK(!i64From(enc("12345678901234567890"), v)); // 20-digit cap + CHECK(!i64From(enc("-"), v)); // bare sign + CHECK(!i64From(enc("1a"), v)); // non-digit + CHECK(!i64From(enc(""), v)); // empty +} + +static void testFieldIntRejectsOutOfIntRange() { + // The fixed form of the old provenance strtol TODO: an out-of-int-range field + // FAILS the parse instead of silently narrowing. + int v = 0; + CHECK(intFrom(enc("2147483647"), v)); CHECK(v == 2147483647); + CHECK(intFrom(enc("-2147483648"), v)); CHECK(v == -2147483647 - 1); + CHECK(!intFrom(enc("2147483648"), v)); + CHECK(!intFrom(enc("3000000000"), v)); +} + +// --- Cursor: fieldSizeT / fieldDouble ------------------------------------------ + +static void testFieldSizeT() { + std::size_t v = 1; + CHECK(sizeFrom(enc("0"), v)); CHECK(v == 0); + CHECK(sizeFrom(enc("4096"), v)); CHECK(v == 4096); + CHECK(!sizeFrom(enc("-1"), v)); // sign = non-digit + CHECK(!sizeFrom(enc(std::string(21, '9')), v)); // 21-digit cap + CHECK(!sizeFrom(enc("18446744073709551616"), v)); // overflow guard +} + +static void testFieldDoubleRoundTrip() { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", 3141.592653589793); + double v = 0; + CHECK(dblFrom(enc(buf), v)); + CHECK(v == 3141.592653589793); + CHECK(!dblFrom(enc("1.5x"), v)); // trailing bytes +} + +// --- parseUnsignedDecimal (the bank_sync generation core) ----------------------- + +static void testParseUnsignedDecimal() { + std::int64_t v = 0; + CHECK(wire::parseUnsignedDecimal("0", v) && v == 0); + CHECK(wire::parseUnsignedDecimal("1721947293", v) && v == 1721947293); + CHECK(wire::parseUnsignedDecimal("9223372036854775807", v) && v == 9223372036854775807LL); + CHECK(!wire::parseUnsignedDecimal("", v)); + CHECK(!wire::parseUnsignedDecimal("+5", v)); // sign rejected (non-digit) + CHECK(!wire::parseUnsignedDecimal("-5", v)); + CHECK(!wire::parseUnsignedDecimal("12a", v)); + CHECK(!wire::parseUnsignedDecimal("9223372036854775808", v)); // overflow + CHECK(!wire::parseUnsignedDecimal(std::string(40, '9'), v)); // long run cannot wrap +} + +int main() { + testPutFieldExactBytes(); + testFieldRoundTripIncludingSeparators(); + testLiteralMismatchFails(); + testFieldRejectsMalformedLengths(); + testFailureLatchesOk(); + testFieldInt64AcceptsAndRejects(); + testFieldIntRejectsOutOfIntRange(); + testFieldSizeT(); + testFieldDoubleRoundTrip(); + testParseUnsignedDecimal(); + + if (g_fail == 0) std::printf("wire: all tests passed\n"); + else std::printf("wire: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} From 847936f813d2060cd7663ae31a1d9ede739f4131 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 20:48:56 -0400 Subject: [PATCH 19/40] =?UTF-8?q?Q-W1=20pt2:=20core/shell/app=20relocation?= =?UTF-8?q?=20+=20sub-namespaces;=20one=20concrete=20ui::Rect=20(LTRB=20fo?= =?UTF-8?q?rk=20retired);=20slot=5Fmap=20split=20from=20bank=5Fbook;=20Ban?= =?UTF-8?q?kIndex=E2=86=92BankModel;=2059/59=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 237 +++++------ src/actions.cpp | 21 +- src/actions.h | 1 + src/{ => app}/main.cpp | 23 +- src/bank_panel.cpp | 93 ++--- src/bank_panel.h | 3 +- src/{ => core/audio}/peaks.cpp | 6 +- src/{ => core/audio}/peaks.h | 4 +- src/{ => core/capture}/batch_capture.cpp | 6 +- src/{ => core/capture}/batch_capture.h | 4 +- src/{ => core/capture}/capture_paths.cpp | 6 +- src/{ => core/capture}/capture_paths.h | 8 +- src/{ => core/capture}/insert_plan.cpp | 6 +- src/{ => core/capture}/insert_plan.h | 4 +- src/{ => core/capture}/realtime_record.cpp | 6 +- src/{ => core/capture}/realtime_record.h | 10 +- src/{ => core/capture}/render_settings.cpp | 6 +- src/{ => core/capture}/render_settings.h | 8 +- src/{ => core/capture}/tail_control.cpp | 6 +- src/{ => core/capture}/tail_control.h | 6 +- src/{ => core/capture}/wav_trim.cpp | 6 +- src/{ => core/capture}/wav_trim.h | 8 +- .../instrument/engine}/master_gain.cpp | 12 +- .../instrument/engine}/master_gain.h | 4 +- .../instrument/engine}/pitch_shift.cpp | 6 +- .../instrument/engine}/pitch_shift.h | 8 +- .../instrument/engine}/sampler_core.cpp | 4 +- .../instrument/engine}/sampler_core.h | 17 +- .../instrument/engine}/velocity_curve.cpp | 17 +- .../instrument/engine}/velocity_curve.h | 4 +- .../instrument/map}/bank_sync.cpp | 6 +- src/{vst => core/instrument/map}/bank_sync.h | 8 +- .../instrument/map}/bridge_marshal.cpp | 6 +- .../instrument/map}/bridge_marshal.h | 4 +- .../instrument/map}/note_entry.cpp | 6 +- src/{vst => core/instrument/map}/note_entry.h | 4 +- .../instrument/map}/sample_map.cpp | 20 +- src/{vst => core/instrument/map}/sample_map.h | 16 +- .../instrument/map}/trigger_seam.cpp | 6 +- .../instrument/map}/trigger_seam.h | 4 +- .../instrument/ui}/browser_scroll.cpp | 26 +- .../instrument/ui}/browser_scroll.h | 6 +- .../instrument/ui}/capture_browser.cpp | 38 +- .../instrument/ui}/capture_browser.h | 10 +- src/core/instrument/ui/curve_popup.cpp | 41 ++ src/{vst => core/instrument/ui}/curve_popup.h | 6 +- .../instrument/ui}/editor_geometry.cpp | 90 ++-- .../instrument/ui}/editor_geometry.h | 29 +- .../instrument/ui}/embed_strip.cpp | 24 +- src/{vst => core/instrument/ui}/embed_strip.h | 8 +- .../instrument/ui}/envelope_edit.cpp | 31 +- .../instrument/ui}/envelope_edit.h | 8 +- .../instrument/ui}/envelope_overlay.cpp | 39 +- .../instrument/ui}/envelope_overlay.h | 32 +- .../instrument/ui}/keyboard_strip.cpp | 32 +- .../instrument/ui}/keyboard_strip.h | 10 +- src/{vst => core/instrument/ui}/knob_deck.cpp | 35 +- src/{vst => core/instrument/ui}/knob_deck.h | 6 +- .../instrument/ui}/param_slider.cpp | 65 +-- .../instrument/ui}/param_slider.h | 8 +- .../instrument/ui}/waveform_view.cpp | 22 +- .../instrument/ui}/waveform_view.h | 18 +- src/core/json/json.h | 2 +- src/{ => core/model}/bank_book.cpp | 174 +------- src/{ => core/model}/bank_book.h | 121 +----- src/{ => core/model}/bank_model.cpp | 30 +- src/{ => core/model}/bank_model.h | 18 +- src/{ => core/model}/owned_manifest.cpp | 6 +- src/{ => core/model}/owned_manifest.h | 10 +- src/{ => core/model}/provenance.cpp | 6 +- src/{ => core/model}/provenance.h | 4 +- src/core/model/slot_map.cpp | 145 +++++++ src/core/model/slot_map.h | 106 +++++ src/core/namespaces.h | 48 +++ src/{ => core/reclaim}/prune_reconcile.cpp | 6 +- src/{ => core/reclaim}/prune_reconcile.h | 6 +- src/{ => core/ui}/action_bar.cpp | 6 +- src/{ => core/ui}/action_bar.h | 16 +- src/{ => core/ui}/bank_grid.cpp | 6 +- src/{ => core/ui}/bank_grid.h | 16 +- src/{ => core/ui}/card_drag.cpp | 6 +- src/{ => core/ui}/card_drag.h | 8 +- src/{ => core/ui}/card_meta.cpp | 6 +- src/{ => core/ui}/card_meta.h | 4 +- src/{ => core/ui}/component_geometry.cpp | 6 +- src/{ => core/ui}/component_geometry.h | 18 +- src/{ => core/ui}/drag_out.cpp | 6 +- src/{ => core/ui}/drag_out.h | 16 +- src/{ => core/ui}/footer_bar.cpp | 6 +- src/{ => core/ui}/footer_bar.h | 28 +- src/{ => core/ui}/mode_enable.cpp | 8 +- src/{ => core/ui}/mode_enable.h | 4 +- src/{ => core/ui}/overflow_menu.cpp | 6 +- src/{ => core/ui}/overflow_menu.h | 29 +- src/{ => core/ui}/prune_button.cpp | 6 +- src/{ => core/ui}/prune_button.h | 29 +- src/core/ui/rect.h | 56 +++ src/{ => core/ui}/tab_strip.cpp | 6 +- src/{ => core/ui}/tab_strip.h | 16 +- src/{ => core/ui}/theme.cpp | 6 +- src/{ => core/ui}/theme.h | 4 +- src/{ => core/ui}/tooltip.cpp | 6 +- src/{ => core/ui}/tooltip.h | 4 +- src/core/util/clamp01.h | 14 + src/core/util/file_bytes.cpp | 4 +- src/core/util/file_bytes.h | 4 +- src/{ => core/version}/app_version.cpp | 6 +- src/{ => core/version}/app_version.h | 4 +- src/{ => core/version}/version_generated.h.in | 0 src/{ => core/view}/guid_diff.cpp | 6 +- src/{ => core/view}/guid_diff.h | 4 +- src/{ => core/view}/lane_keys.cpp | 6 +- src/{ => core/view}/lane_keys.h | 4 +- src/{ => core/view}/mode_switch.cpp | 6 +- src/{ => core/view}/mode_switch.h | 27 +- src/{ => core/view}/view_mode_model.cpp | 8 +- src/{ => core/view}/view_mode_model.h | 0 src/{ => core/view}/view_tree.cpp | 6 +- src/{ => core/view}/view_tree.h | 6 +- src/{ => core/wire}/assignment_request.cpp | 6 +- src/{ => core/wire}/assignment_request.h | 6 +- src/{ => core/wire}/instrument_drop.cpp | 10 +- src/{ => core/wire}/instrument_drop.h | 4 +- src/{ => core/wire}/sample_usage.cpp | 6 +- src/{ => core/wire}/sample_usage.h | 4 +- src/ext_keys.h | 3 +- src/ingest.cpp | 17 +- src/ingest.h | 1 + src/persist.cpp | 21 +- src/persist.h | 31 +- src/{ => shell/actions}/drag_out_win.cpp | 3 +- src/{ => shell/actions}/drag_out_win.h | 1 + .../actions}/instrument_drop_win.cpp | 7 +- src/{ => shell/actions}/instrument_drop_win.h | 1 + src/{ => shell/capture}/capture.cpp | 7 +- src/{ => shell/capture}/capture.h | 5 +- src/{ => shell/capture}/capture_realtime.cpp | 13 +- src/{ => shell/capture}/insert.cpp | 11 +- src/{ => shell/capture}/insert.h | 3 +- src/{ => shell/capture}/item_read.cpp | 3 +- src/{ => shell/capture}/item_read.h | 1 + src/{ => shell/capture}/provenance_shell.cpp | 9 +- src/{ => shell/capture}/provenance_shell.h | 3 +- src/{ => shell/capture}/track_guid.cpp | 3 +- src/{ => shell/capture}/track_guid.h | 1 + .../instrument}/reaper_bridge.cpp | 7 +- src/{vst => shell/instrument}/reaper_bridge.h | 1 + .../instrument}/reasampler_embed.cpp | 49 +-- .../instrument}/reasampler_embed.h | 3 +- .../instrument}/reasampler_uid.h | 0 .../instrument}/reasampler_vst.h | 3 +- src/{vst => shell/instrument}/vst_entry.cpp | 5 +- src/{ => shell/panel}/draw_kit.cpp | 5 +- src/{ => shell/panel}/draw_kit.h | 7 +- src/{ => shell/persist}/usage_scan.cpp | 11 +- src/{ => shell/persist}/usage_scan.h | 1 + src/{ => shell/view}/view.cpp | 11 +- src/{ => shell/view}/view.h | 3 +- src/vst/curve_popup.cpp | 41 -- src/vst/reasampler_editor.cpp | 387 +++++++++--------- src/vst/reasampler_editor.h | 15 +- src/vst/reasampler_processor.cpp | 17 +- src/vst/reasampler_processor.h | 7 +- tests/test_action_bar.cpp | 3 +- tests/test_app_version.cpp | 3 +- tests/test_app_version_padding.cpp | 3 +- tests/test_assignment_request.cpp | 3 +- tests/test_bank_book.cpp | 17 +- tests/test_bank_grid.cpp | 3 +- tests/test_bank_model.cpp | 61 +-- tests/test_bank_sync.cpp | 6 +- tests/test_batch_capture.cpp | 3 +- tests/test_bridge_marshal.cpp | 7 +- tests/test_browser_scroll.cpp | 35 +- tests/test_capture_browser.cpp | 97 ++--- tests/test_capture_paths.cpp | 7 +- tests/test_card_drag.cpp | 3 +- tests/test_card_meta.cpp | 3 +- tests/test_component_geometry.cpp | 3 +- tests/test_curve_popup.cpp | 65 +-- tests/test_drag_out.cpp | 3 +- tests/test_editor_geometry.cpp | 197 ++++----- tests/test_embed_strip.cpp | 87 ++-- tests/test_envelope_edit.cpp | 33 +- tests/test_envelope_overlay.cpp | 93 ++--- tests/test_file_bytes.cpp | 1 + tests/test_footer_bar.cpp | 3 +- tests/test_guid_diff.cpp | 3 +- tests/test_insert_plan.cpp | 3 +- tests/test_instrument_drop.cpp | 5 +- tests/test_json.cpp | 1 + tests/test_keyboard_strip.cpp | 73 ++-- tests/test_knob_deck.cpp | 65 +-- tests/test_lane_keys.cpp | 3 +- tests/test_master_gain.cpp | 7 +- tests/test_mode_enable.cpp | 5 +- tests/test_mode_switch.cpp | 3 +- tests/test_note_entry.cpp | 7 +- tests/test_overflow_menu.cpp | 3 +- tests/test_owned_manifest.cpp | 3 +- tests/test_param_slider.cpp | 127 +++--- tests/test_peaks.cpp | 3 +- tests/test_pitch_shift.cpp | 3 +- tests/test_provenance.cpp | 11 +- tests/test_prune_button.cpp | 3 +- tests/test_prune_reconcile.cpp | 6 +- tests/test_realtime_record.cpp | 3 +- tests/test_render_settings.cpp | 3 +- tests/test_sample_map.cpp | 33 +- tests/test_sample_usage.cpp | 6 +- tests/test_sampler_core.cpp | 15 +- tests/test_tab_strip.cpp | 3 +- tests/test_tail_control.cpp | 3 +- tests/test_theme.cpp | 3 +- tests/test_tooltip.cpp | 3 +- tests/test_trigger_seam.cpp | 7 +- tests/test_velocity_curve.cpp | 7 +- tests/test_view_mode_model.cpp | 5 +- tests/test_view_tree.cpp | 3 +- tests/test_wav_trim.cpp | 3 +- tests/test_waveform_view.cpp | 73 ++-- tests/test_wire.cpp | 1 + 222 files changed, 2247 insertions(+), 2079 deletions(-) rename src/{ => app}/main.cpp (99%) rename src/{ => core/audio}/peaks.cpp (98%) rename src/{ => core/audio}/peaks.h (99%) rename src/{ => core/capture}/batch_capture.cpp (96%) rename src/{ => core/capture}/batch_capture.h (98%) rename src/{ => core/capture}/capture_paths.cpp (99%) rename src/{ => core/capture}/capture_paths.h (98%) rename src/{ => core/capture}/insert_plan.cpp (93%) rename src/{ => core/capture}/insert_plan.h (98%) rename src/{ => core/capture}/realtime_record.cpp (98%) rename src/{ => core/capture}/realtime_record.h (98%) rename src/{ => core/capture}/render_settings.cpp (98%) rename src/{ => core/capture}/render_settings.h (98%) rename src/{ => core/capture}/tail_control.cpp (97%) rename src/{ => core/capture}/tail_control.h (95%) rename src/{ => core/capture}/wav_trim.cpp (98%) rename src/{ => core/capture}/wav_trim.h (97%) rename src/{vst => core/instrument/engine}/master_gain.cpp (85%) rename src/{vst => core/instrument/engine}/master_gain.h (97%) rename src/{vst => core/instrument/engine}/pitch_shift.cpp (99%) rename src/{vst => core/instrument/engine}/pitch_shift.h (98%) rename src/{vst => core/instrument/engine}/sampler_core.cpp (99%) rename src/{vst => core/instrument/engine}/sampler_core.h (98%) rename src/{vst => core/instrument/engine}/velocity_curve.cpp (97%) rename src/{vst => core/instrument/engine}/velocity_curve.h (99%) rename src/{vst => core/instrument/map}/bank_sync.cpp (95%) rename src/{vst => core/instrument/map}/bank_sync.h (96%) rename src/{vst => core/instrument/map}/bridge_marshal.cpp (80%) rename src/{vst => core/instrument/map}/bridge_marshal.h (95%) rename src/{vst => core/instrument/map}/note_entry.cpp (96%) rename src/{vst => core/instrument/map}/note_entry.h (95%) rename src/{vst => core/instrument/map}/sample_map.cpp (98%) rename src/{vst => core/instrument/map}/sample_map.h (98%) rename src/{vst => core/instrument/map}/trigger_seam.cpp (87%) rename src/{vst => core/instrument/map}/trigger_seam.h (97%) rename src/{vst => core/instrument/ui}/browser_scroll.cpp (88%) rename src/{vst => core/instrument/ui}/browser_scroll.h (97%) rename src/{vst => core/instrument/ui}/capture_browser.cpp (68%) rename src/{vst => core/instrument/ui}/capture_browser.h (96%) create mode 100644 src/core/instrument/ui/curve_popup.cpp rename src/{vst => core/instrument/ui}/curve_popup.h (94%) rename src/{vst => core/instrument/ui}/editor_geometry.cpp (64%) rename src/{vst => core/instrument/ui}/editor_geometry.h (91%) rename src/{vst => core/instrument/ui}/embed_strip.cpp (78%) rename src/{vst => core/instrument/ui}/embed_strip.h (95%) rename src/{vst => core/instrument/ui}/envelope_edit.cpp (88%) rename src/{vst => core/instrument/ui}/envelope_edit.h (96%) rename src/{vst => core/instrument/ui}/envelope_overlay.cpp (90%) rename src/{vst => core/instrument/ui}/envelope_overlay.h (92%) rename src/{vst => core/instrument/ui}/keyboard_strip.cpp (86%) rename src/{vst => core/instrument/ui}/keyboard_strip.h (96%) rename src/{vst => core/instrument/ui}/knob_deck.cpp (79%) rename src/{vst => core/instrument/ui}/knob_deck.h (97%) rename src/{vst => core/instrument/ui}/param_slider.cpp (71%) rename src/{vst => core/instrument/ui}/param_slider.h (97%) rename src/{vst => core/instrument/ui}/waveform_view.cpp (88%) rename src/{vst => core/instrument/ui}/waveform_view.h (89%) rename src/{ => core/model}/bank_book.cpp (84%) rename src/{ => core/model}/bank_book.h (78%) rename src/{ => core/model}/bank_model.cpp (96%) rename src/{ => core/model}/bank_model.h (95%) rename src/{ => core/model}/owned_manifest.cpp (97%) rename src/{ => core/model}/owned_manifest.h (93%) rename src/{ => core/model}/provenance.cpp (98%) rename src/{ => core/model}/provenance.h (99%) create mode 100644 src/core/model/slot_map.cpp create mode 100644 src/core/model/slot_map.h create mode 100644 src/core/namespaces.h rename src/{ => core/reclaim}/prune_reconcile.cpp (97%) rename src/{ => core/reclaim}/prune_reconcile.h (99%) rename src/{ => core/ui}/action_bar.cpp (98%) rename src/{ => core/ui}/action_bar.h (96%) rename src/{ => core/ui}/bank_grid.cpp (98%) rename src/{ => core/ui}/bank_grid.h (97%) rename src/{ => core/ui}/card_drag.cpp (97%) rename src/{ => core/ui}/card_drag.h (98%) rename src/{ => core/ui}/card_meta.cpp (96%) rename src/{ => core/ui}/card_meta.h (98%) rename src/{ => core/ui}/component_geometry.cpp (97%) rename src/{ => core/ui}/component_geometry.h (95%) rename src/{ => core/ui}/drag_out.cpp (95%) rename src/{ => core/ui}/drag_out.h (96%) rename src/{ => core/ui}/footer_bar.cpp (96%) rename src/{ => core/ui}/footer_bar.h (88%) rename src/{ => core/ui}/mode_enable.cpp (77%) rename src/{ => core/ui}/mode_enable.h (97%) rename src/{ => core/ui}/overflow_menu.cpp (94%) rename src/{ => core/ui}/overflow_menu.h (87%) rename src/{ => core/ui}/prune_button.cpp (94%) rename src/{ => core/ui}/prune_button.h (90%) create mode 100644 src/core/ui/rect.h rename src/{ => core/ui}/tab_strip.cpp (98%) rename src/{ => core/ui}/tab_strip.h (95%) rename src/{ => core/ui}/theme.cpp (99%) rename src/{ => core/ui}/theme.h (99%) rename src/{ => core/ui}/tooltip.cpp (95%) rename src/{ => core/ui}/tooltip.h (98%) create mode 100644 src/core/util/clamp01.h rename src/{ => core/version}/app_version.cpp (98%) rename src/{ => core/version}/app_version.h (99%) rename src/{ => core/version}/version_generated.h.in (100%) rename src/{ => core/view}/guid_diff.cpp (94%) rename src/{ => core/view}/guid_diff.h (98%) rename src/{ => core/view}/lane_keys.cpp (95%) rename src/{ => core/view}/lane_keys.h (98%) rename src/{ => core/view}/mode_switch.cpp (96%) rename src/{ => core/view}/mode_switch.h (84%) rename src/{ => core/view}/view_mode_model.cpp (99%) rename src/{ => core/view}/view_mode_model.h (100%) rename src/{ => core/view}/view_tree.cpp (93%) rename src/{ => core/view}/view_tree.h (93%) rename src/{ => core/wire}/assignment_request.cpp (92%) rename src/{ => core/wire}/assignment_request.h (97%) rename src/{ => core/wire}/instrument_drop.cpp (93%) rename src/{ => core/wire}/instrument_drop.h (99%) rename src/{ => core/wire}/sample_usage.cpp (99%) rename src/{ => core/wire}/sample_usage.h (99%) rename src/{ => shell/actions}/drag_out_win.cpp (99%) rename src/{ => shell/actions}/drag_out_win.h (98%) rename src/{ => shell/actions}/instrument_drop_win.cpp (96%) rename src/{ => shell/actions}/instrument_drop_win.h (99%) rename src/{ => shell/capture}/capture.cpp (99%) rename src/{ => shell/capture}/capture.h (98%) rename src/{ => shell/capture}/capture_realtime.cpp (99%) rename src/{ => shell/capture}/insert.cpp (97%) rename src/{ => shell/capture}/insert.h (97%) rename src/{ => shell/capture}/item_read.cpp (94%) rename src/{ => shell/capture}/item_read.h (98%) rename src/{ => shell/capture}/provenance_shell.cpp (95%) rename src/{ => shell/capture}/provenance_shell.h (98%) rename src/{ => shell/capture}/track_guid.cpp (91%) rename src/{ => shell/capture}/track_guid.h (97%) rename src/{vst => shell/instrument}/reaper_bridge.cpp (97%) rename src/{vst => shell/instrument}/reaper_bridge.h (99%) rename src/{vst => shell/instrument}/reasampler_embed.cpp (87%) rename src/{vst => shell/instrument}/reasampler_embed.h (97%) rename src/{vst => shell/instrument}/reasampler_uid.h (100%) rename src/{vst => shell/instrument}/reasampler_vst.h (95%) rename src/{vst => shell/instrument}/vst_entry.cpp (95%) rename src/{ => shell/panel}/draw_kit.cpp (98%) rename src/{ => shell/panel}/draw_kit.h (96%) rename src/{ => shell/persist}/usage_scan.cpp (96%) rename src/{ => shell/persist}/usage_scan.h (99%) rename src/{ => shell/view}/view.cpp (99%) rename src/{ => shell/view}/view.h (98%) delete mode 100644 src/vst/curve_popup.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c17901..8c9989d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,7 +69,7 @@ endif() # Regenerated at configure time whenever either changes; the exact string is substituted # verbatim and the channel bit fans out through app_version. configure_file( - ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in + ${CMAKE_CURRENT_SOURCE_DIR}/src/core/version/version_generated.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/version_generated.h @ONLY) @@ -107,7 +107,7 @@ target_include_directories(file_bytes PUBLIC src) # 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere. # The sampler's heart: Sample metadata + BankIndex (Milestone 1). # --------------------------------------------------------------------------- -add_library(bank_model STATIC src/bank_model.cpp) +add_library(bank_model STATIC src/core/model/bank_model.cpp) target_include_directories(bank_model PUBLIC src) target_link_libraries(bank_model PRIVATE json) @@ -115,7 +115,7 @@ target_link_libraries(bank_model PRIVATE json) # 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from # raw PCM (Milestone 2). A sibling pure lib, kept distinct from bank_model. # --------------------------------------------------------------------------- -add_library(peaks STATIC src/peaks.cpp) +add_library(peaks STATIC src/core/audio/peaks.cpp) target_include_directories(peaks PUBLIC src) # --------------------------------------------------------------------------- @@ -123,7 +123,7 @@ target_include_directories(peaks PUBLIC src) # file-name / project-relative path derivation for the capture shell (M3). # Split out so the fiddly path logic is unit-tested outside the DAW. # --------------------------------------------------------------------------- -add_library(capture_paths STATIC src/capture_paths.cpp) +add_library(capture_paths STATIC src/core/capture/capture_paths.cpp) target_include_directories(capture_paths PUBLIC src) # --------------------------------------------------------------------------- @@ -132,7 +132,7 @@ target_include_directories(capture_paths PUBLIC src) # cache key for the docked bank_panel (M5). Split out so the layout logic is # unit-tested outside the DAW; the panel shell (SWELL/LICE/PCM) is DAW-verified. # --------------------------------------------------------------------------- -add_library(bank_grid STATIC src/bank_grid.cpp) +add_library(bank_grid STATIC src/core/ui/bank_grid.cpp) target_include_directories(bank_grid PUBLIC src) # --------------------------------------------------------------------------- @@ -142,7 +142,7 @@ target_include_directories(bank_grid PUBLIC src) # unit-tested outside the DAW; the bank_panel header strip that draws it and # routes clicks to view::applyMode is DAW-verified. Mirror of bank_grid. # --------------------------------------------------------------------------- -add_library(mode_switch STATIC src/mode_switch.cpp) +add_library(mode_switch STATIC src/core/view/mode_switch.cpp) target_include_directories(mode_switch PUBLIC src) # --------------------------------------------------------------------------- @@ -153,7 +153,7 @@ target_include_directories(mode_switch PUBLIC src) # overflow/scroll math is unit-tested outside the DAW; the bank_panel region # that draws it and routes clicks is DAW-verified. Mirror of mode_switch. # --------------------------------------------------------------------------- -add_library(tab_strip STATIC src/tab_strip.cpp) +add_library(tab_strip STATIC src/core/ui/tab_strip.cpp) target_include_directories(tab_strip PUBLIC src) # --------------------------------------------------------------------------- @@ -162,7 +162,7 @@ target_include_directories(tab_strip PUBLIC src) # visibility derivation + parking/restore planner + JSON round-trip. Mirror of # bank_model; the folder tree is an INPUT supplied by the D2 shell. # --------------------------------------------------------------------------- -add_library(view_mode_model STATIC src/view_mode_model.cpp) +add_library(view_mode_model STATIC src/core/view/view_mode_model.cpp) target_include_directories(view_mode_model PUBLIC src) target_link_libraries(view_mode_model PRIVATE json) # The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE @@ -177,7 +177,7 @@ target_link_libraries(view_mode_model PUBLIC lane_keys) # view.cpp; this fiddly folder-depth walk is unit-tested here (mirrors # capture_paths splitting the path math out of the capture shell). # --------------------------------------------------------------------------- -add_library(view_tree STATIC src/view_tree.cpp) +add_library(view_tree STATIC src/core/view/view_tree.cpp) target_include_directories(view_tree PUBLIC src) target_link_libraries(view_tree PUBLIC view_mode_model) @@ -189,7 +189,7 @@ target_link_libraries(view_tree PUBLIC view_mode_model) # live track/item GUID set and applies the tags is DAW-verified. Mirror of # view_tree splitting the folder-depth walk out of view.cpp. # --------------------------------------------------------------------------- -add_library(guid_diff STATIC src/guid_diff.cpp) +add_library(guid_diff STATIC src/core/view/guid_diff.cpp) target_include_directories(guid_diff PUBLIC src) # --------------------------------------------------------------------------- @@ -200,7 +200,7 @@ target_include_directories(guid_diff PUBLIC src) # exemption) and #2 (name-keyed identity survives ordinal renumber). Split out # so the prefix rule is unit-tested; view.cpp reads the names from REAPER. # --------------------------------------------------------------------------- -add_library(lane_keys STATIC src/lane_keys.cpp) +add_library(lane_keys STATIC src/core/view/lane_keys.cpp) target_include_directories(lane_keys PUBLIC src) # --------------------------------------------------------------------------- @@ -209,7 +209,7 @@ target_include_directories(lane_keys PUBLIC src) # load-bearing bit computation (no silent stretch, opt-in conform) is # unit-tested outside the DAW; the InsertMedia call itself is DAW-verified. # --------------------------------------------------------------------------- -add_library(insert_plan STATIC src/insert_plan.cpp) +add_library(insert_plan STATIC src/core/capture/insert_plan.cpp) target_include_directories(insert_plan PUBLIC src) # --------------------------------------------------------------------------- @@ -220,7 +220,7 @@ target_include_directories(insert_plan PUBLIC src) # the DAW; the render-driving + selection reads stay in capture.cpp / main.cpp. # Depends on bank_model for the pure SourceMode enum. # --------------------------------------------------------------------------- -add_library(render_settings STATIC src/render_settings.cpp) +add_library(render_settings STATIC src/core/capture/render_settings.cpp) target_include_directories(render_settings PUBLIC src) target_link_libraries(render_settings PUBLIC bank_model) @@ -233,7 +233,7 @@ target_link_libraries(render_settings PUBLIC bank_model) # the selection read, transient re-selection, and render loop stay in main.cpp. # No dependency on bank_model — it takes plain ranges/values at its boundary. # --------------------------------------------------------------------------- -add_library(batch_capture STATIC src/batch_capture.cpp) +add_library(batch_capture STATIC src/core/capture/batch_capture.cpp) target_include_directories(batch_capture PUBLIC src) # --------------------------------------------------------------------------- @@ -244,7 +244,7 @@ target_include_directories(batch_capture PUBLIC src) # outside the DAW; the bank_panel footer that draws it + routes clicks is # DAW-verified. Depends on render_settings for the pure TailMode enum + caps. # --------------------------------------------------------------------------- -add_library(tail_control STATIC src/tail_control.cpp) +add_library(tail_control STATIC src/core/capture/tail_control.cpp) target_include_directories(tail_control PUBLIC src) target_link_libraries(tail_control PUBLIC render_settings) target_link_libraries(tail_control PRIVATE json) @@ -257,7 +257,7 @@ target_link_libraries(tail_control PRIVATE json) # sample between banks, JSON round-trip + legacy-bank_index→pool migration. # Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched). # --------------------------------------------------------------------------- -add_library(bank_book STATIC src/bank_book.cpp) +add_library(bank_book STATIC src/core/model/bank_book.cpp src/core/model/slot_map.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) target_link_libraries(bank_book PRIVATE json) @@ -271,7 +271,7 @@ target_link_libraries(bank_book PRIVATE json) # pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes + # persists it; Phase R (R1/R2) consumes it — no prune logic here. # --------------------------------------------------------------------------- -add_library(owned_manifest STATIC src/owned_manifest.cpp) +add_library(owned_manifest STATIC src/core/model/owned_manifest.cpp) target_include_directories(owned_manifest PUBLIC src) target_link_libraries(owned_manifest PRIVATE json) @@ -284,7 +284,7 @@ target_link_libraries(owned_manifest PRIVATE json) # pure function; R2/R3 wrap the two ends (folder enumeration + deletion) in the # shell. Standalone — depends only on the standard library. # --------------------------------------------------------------------------- -add_library(prune_reconcile STATIC src/prune_reconcile.cpp) +add_library(prune_reconcile STATIC src/core/reclaim/prune_reconcile.cpp) target_include_directories(prune_reconcile PUBLIC src) # --------------------------------------------------------------------------- @@ -295,7 +295,7 @@ target_include_directories(prune_reconcile PUBLIC src) # outside the DAW; the bank_panel footer that draws it and dispatches the # "Prune bank folder" command is DAW-verified. Mirror of mode_switch / tab_strip. # --------------------------------------------------------------------------- -add_library(prune_button STATIC src/prune_button.cpp) +add_library(prune_button STATIC src/core/ui/prune_button.cpp) target_include_directories(prune_button PUBLIC src) # --------------------------------------------------------------------------- @@ -306,7 +306,7 @@ target_include_directories(prune_button PUBLIC src) # the DAW; the transport/temp-track/send/file-move recipe stays in capture.cpp. # Depends on bank_model for the pure Sample / SourceMode types. # --------------------------------------------------------------------------- -add_library(realtime_record STATIC src/realtime_record.cpp) +add_library(realtime_record STATIC src/core/capture/realtime_record.cpp) target_include_directories(realtime_record PUBLIC src) target_link_libraries(realtime_record PUBLIC bank_model) @@ -320,7 +320,7 @@ target_link_libraries(realtime_record PUBLIC bank_model) # patched RIFF/data size fields). The file read/write/truncate I/O stays in the # realtime shell. Depends on peaks for the AudioSample float alias. # --------------------------------------------------------------------------- -add_library(wav_trim STATIC src/wav_trim.cpp) +add_library(wav_trim STATIC src/core/capture/wav_trim.cpp) target_include_directories(wav_trim PUBLIC src) target_link_libraries(wav_trim PUBLIC peaks) @@ -334,7 +334,7 @@ target_link_libraries(wav_trim PUBLIC peaks) # DAW; the ext-state write/read (persist) and the show-version action (main) are shell. # Depends on the generated header in the build tree (PUBLIC so every consumer sees it). # --------------------------------------------------------------------------- -add_library(app_version STATIC src/app_version.cpp) +add_library(app_version STATIC src/core/version/app_version.cpp) target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) # --------------------------------------------------------------------------- @@ -347,7 +347,7 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge # registration stay in the shell (main.cpp / actions.cpp). No dependency on # bank_model — it takes plain strings/values at its boundary. # --------------------------------------------------------------------------- -add_library(provenance STATIC src/provenance.cpp) +add_library(provenance STATIC src/core/model/provenance.cpp) target_include_directories(provenance PUBLIC src) target_link_libraries(provenance PRIVATE wire) @@ -361,7 +361,7 @@ target_link_libraries(provenance PRIVATE wire) # depend on is unit-tested outside the DAW; the reader lands in a separate artifact, # so the round-trip test is the contract guard. No dependency — plain strings + int64. # --------------------------------------------------------------------------- -add_library(assignment_request STATIC src/assignment_request.cpp) +add_library(assignment_request STATIC src/core/wire/assignment_request.cpp) target_include_directories(assignment_request PUBLIC src) target_link_libraries(assignment_request PRIVATE wire) @@ -375,7 +375,7 @@ target_link_libraries(assignment_request PRIVATE wire) # matcher. # Linked by BOTH artifacts — the mirror of assignment_request, reversed direction. # --------------------------------------------------------------------------- -add_library(sample_usage STATIC src/sample_usage.cpp) +add_library(sample_usage STATIC src/core/wire/sample_usage.cpp) target_include_directories(sample_usage PUBLIC src) target_link_libraries(sample_usage PRIVATE wire) @@ -390,7 +390,7 @@ target_link_libraries(sample_usage PRIVATE wire) # initiation (drag_out_win) and the bank_panel gesture hook are DAW-verified. Mirror # of mode_switch. # --------------------------------------------------------------------------- -add_library(drag_out STATIC src/drag_out.cpp) +add_library(drag_out STATIC src/core/ui/drag_out.cpp) target_include_directories(drag_out PUBLIC src) # --------------------------------------------------------------------------- @@ -404,13 +404,13 @@ target_include_directories(drag_out PUBLIC src) # parallel byte writer — so the cross-artifact contract cannot drift; links # sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and # NEITHER SDK. The class-ID string derives from the FROZEN UID macros -# (src/vst/reasampler_uid.h, SDK-free), channel-selected via the generated +# (src/shell/instrument/reasampler_uid.h, SDK-free), channel-selected via the generated # version header — hence the generated include dir. The round-trip test parses # the container and decodes back through the instrument's own reader. Mirror of # assignment_request. # --------------------------------------------------------------------------- -add_library(instrument_drop STATIC src/instrument_drop.cpp) -target_include_directories(instrument_drop PUBLIC src src/vst ${CMAKE_CURRENT_BINARY_DIR}/generated) +add_library(instrument_drop STATIC src/core/wire/instrument_drop.cpp) +target_include_directories(instrument_drop PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) target_link_libraries(instrument_drop PUBLIC sample_map) # --------------------------------------------------------------------------- @@ -423,7 +423,7 @@ target_link_libraries(instrument_drop PUBLIC sample_map) # switching the visual direction is a one-file edit. The draw shell (draw_kit) turns # a KitColor into a LICE_pixel at the boundary. Mirror of mode_switch — pure. # --------------------------------------------------------------------------- -add_library(theme STATIC src/theme.cpp) +add_library(theme STATIC src/core/ui/theme.cpp) target_include_directories(theme PUBLIC src) # --------------------------------------------------------------------------- @@ -436,7 +436,7 @@ target_include_directories(theme PUBLIC src) # SliderGeometry/ListRowBox avoid the existing ButtonRect/CellRect collisions. # Mirror of prune_button — pure, CTest-covered. # --------------------------------------------------------------------------- -add_library(component_geometry STATIC src/component_geometry.cpp) +add_library(component_geometry STATIC src/core/ui/component_geometry.cpp) target_include_directories(component_geometry PUBLIC src) # --------------------------------------------------------------------------- @@ -451,7 +451,7 @@ target_include_directories(component_geometry PUBLIC src) # Main_OnCommand dispatch + kbd_getTextFromCmd query are DAW-verified. Mirror of # mode_switch / prune_button. # --------------------------------------------------------------------------- -add_library(action_bar STATIC src/action_bar.cpp) +add_library(action_bar STATIC src/core/ui/action_bar.cpp) target_include_directories(action_bar PUBLIC src) # --------------------------------------------------------------------------- @@ -465,7 +465,7 @@ target_include_directories(action_bar PUBLIC src) # DAW-verified. Reuses prune_button's FooterRect input type. Mirror of action_bar / # mode_switch / prune_button. # --------------------------------------------------------------------------- -add_library(footer_bar STATIC src/footer_bar.cpp) +add_library(footer_bar STATIC src/core/ui/footer_bar.cpp) target_include_directories(footer_bar PUBLIC src) target_link_libraries(footer_bar PUBLIC prune_button) @@ -478,7 +478,7 @@ target_link_libraries(footer_bar PUBLIC prune_button) # L1-kit draw + TrackPopupMenu popup + NamedCommandLookup/Main_OnCommand dispatch are # DAW-verified. Mirror of prune_button / mode_switch. # --------------------------------------------------------------------------- -add_library(overflow_menu STATIC src/overflow_menu.cpp) +add_library(overflow_menu STATIC src/core/ui/overflow_menu.cpp) target_include_directories(overflow_menu PUBLIC src) # --------------------------------------------------------------------------- @@ -490,7 +490,7 @@ target_include_directories(overflow_menu PUBLIC src) # draws disabled buttons in the kit Disabled state. Depends on view_mode_model for the # seed mode-id constants (kArrangeModeId / kDesignModeId — ONE home for the ids). # --------------------------------------------------------------------------- -add_library(mode_enable STATIC src/mode_enable.cpp) +add_library(mode_enable STATIC src/core/ui/mode_enable.cpp) target_include_directories(mode_enable PUBLIC src) target_link_libraries(mode_enable PUBLIC view_mode_model) @@ -502,7 +502,7 @@ target_link_libraries(mode_enable PUBLIC view_mode_model) # prefix strip are unit-tested outside the DAW; the bank_panel hover timer + LICE overlay # draw are DAW-verified. Mirror of prune_button / component_geometry. # --------------------------------------------------------------------------- -add_library(tooltip STATIC src/tooltip.cpp) +add_library(tooltip STATIC src/core/ui/tooltip.cpp) target_include_directories(tooltip PUBLIC src) # --------------------------------------------------------------------------- @@ -513,7 +513,7 @@ target_include_directories(tooltip PUBLIC src) # edge cases) is unit-tested outside the DAW; the bank_panel kit-text overlay draw is # DAW-verified. Mirror of tooltip's prefix-strip helper — pure, CTest-covered. # --------------------------------------------------------------------------- -add_library(card_meta STATIC src/card_meta.cpp) +add_library(card_meta STATIC src/core/ui/card_meta.cpp) target_include_directories(card_meta PUBLIC src) # --------------------------------------------------------------------------- @@ -526,7 +526,7 @@ target_include_directories(card_meta PUBLIC src) # are DAW-verified. Reuses drag_out's PanelClientRect/DragState + bank_grid's CellRect/ # GridSpec. Mirror of drag_out::decideGesture / bank_grid. # --------------------------------------------------------------------------- -add_library(card_drag STATIC src/card_drag.cpp) +add_library(card_drag STATIC src/core/ui/card_drag.cpp) target_include_directories(card_drag PUBLIC src) target_link_libraries(card_drag PUBLIC drag_out bank_grid) @@ -547,8 +547,8 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # because that header drags (via wdltypes.h) into any TU that includes it, which # cannot enter the pure sampler_core. Links only peaks (the AudioSample alias). sampler_core # depends on it (Voice owns two PitchShifters). -add_library(pitch_shift STATIC src/vst/pitch_shift.cpp) -target_include_directories(pitch_shift PUBLIC src src/vst) +add_library(pitch_shift STATIC src/core/instrument/engine/pitch_shift.cpp) +target_include_directories(pitch_shift PUBLIC src) target_link_libraries(pitch_shift PUBLIC peaks) # velocity_curve (S-VIEW-9) — the pure velocity->amp transfer curve (eval + editing/clamp/inverse @@ -556,11 +556,11 @@ target_link_libraries(pitch_shift PUBLIC peaks) # explicit pixel box, not a Rect) so the engine can depend on it WITHOUT gaining a transitive # dependency on the editor's layout types. sampler_core depends on it (KeyZone carries a # VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine. -add_library(velocity_curve STATIC src/vst/velocity_curve.cpp) -target_include_directories(velocity_curve PUBLIC src/vst) +add_library(velocity_curve STATIC src/core/instrument/engine/velocity_curve.cpp) +target_include_directories(velocity_curve PUBLIC src) -add_library(sampler_core STATIC src/vst/sampler_core.cpp) -target_include_directories(sampler_core PUBLIC src src/vst) +add_library(sampler_core STATIC src/core/instrument/engine/sampler_core.cpp) +target_include_directories(sampler_core PUBLIC src) target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve) # --------------------------------------------------------------------------- @@ -671,7 +671,7 @@ add_test(NAME app_version_tests COMMAND app_version_tests) # threaded verbatim or reconstructed from numeric components, at any version, padded or # not — the live suite cannot detect a reconstruct-from-components regression at all. # So: run the SAME version_generated.h.in template through configure_file a second time -# with a SYNTHETIC padded version, and compile the SAME src/app_version.cpp against that +# with a SYNTHETIC padded version, and compile the SAME src/core/version/app_version.cpp against that # header (include-dir substitution — the canary target never sees the live generated/ # dir). The "0.9.01" here is a permanent test fixture, NOT the shipped version (see also # the source-of-truth comment at the top of this file for the live-version invariant); it @@ -688,20 +688,20 @@ function(_configure_padding_canary) # and CMAKE_CURRENT_SOURCE_DIR / CMAKE_CURRENT_BINARY_DIR are inherited read-only. set(REASAMPLER_VERSION "0.9.01") configure_file( - ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in + ${CMAKE_CURRENT_SOURCE_DIR}/src/core/version/version_generated.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated_padding_canary/version_generated.h @ONLY) endfunction() _configure_padding_canary() -# NOTE: app_version_padding_tests deliberately recompiles src/app_version.cpp rather +# NOTE: app_version_padding_tests deliberately recompiles src/core/version/app_version.cpp rather # than linking the app_version library target. This is required for the include-dir # substitution to work — the canary needs to see generated_padding_canary/ instead of # the live generated/ dir. If app_version ever gains a link dependency (e.g. a new # pure-module link), the canary target_link_libraries must mirror it here. add_executable(app_version_padding_tests tests/test_app_version_padding.cpp - src/app_version.cpp) + src/core/version/app_version.cpp) target_include_directories(app_version_padding_tests PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated_padding_canary src) add_test(NAME app_version_padding_tests COMMAND app_version_padding_tests) @@ -800,19 +800,19 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests) # reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; # the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified. # --------------------------------------------------------------------------- -add_library(editor_geometry STATIC src/vst/editor_geometry.cpp) -target_include_directories(editor_geometry PUBLIC src/vst) +add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp) +target_include_directories(editor_geometry PUBLIC src) -add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) -target_include_directories(bridge_marshal PUBLIC src/vst) +add_library(bridge_marshal STATIC src/core/instrument/map/bridge_marshal.cpp) +target_include_directories(bridge_marshal PUBLIC src) # embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the # 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill. # The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside -# the DAW, while the embed shell (src/vst/reasampler_embed.cpp) marshals REAPER's embed +# the DAW, while the embed shell (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed # messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect. -add_library(embed_strip STATIC src/vst/embed_strip.cpp) -target_include_directories(embed_strip PUBLIC src/vst) +add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp) +target_include_directories(embed_strip PUBLIC src) target_link_libraries(embed_strip PUBLIC editor_geometry) # sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank @@ -823,8 +823,8 @@ target_link_libraries(embed_strip PUBLIC editor_geometry) # sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell # (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then # calls these; the process callback stays allocation-free. -add_library(sample_map STATIC src/vst/sample_map.cpp) -target_include_directories(sample_map PUBLIC src/vst src) +add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp) +target_include_directories(sample_map PUBLIC src) # master_gain: the v8 component-state master-gain field validates against the pure taper's # linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor). target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain) @@ -834,16 +834,16 @@ target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_g # grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each # card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for # the shared Rect + contains(). NEITHER SDK. -add_library(capture_browser STATIC src/vst/capture_browser.cpp) -target_include_directories(capture_browser PUBLIC src/vst) +add_library(capture_browser STATIC src/core/instrument/ui/capture_browser.cpp) +target_include_directories(capture_browser PUBLIC src) target_link_libraries(capture_browser PUBLIC editor_geometry) # keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects + # edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's # keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of # embed_strip; links editor_geometry for the shared Rect. NEITHER SDK. -add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp) -target_include_directories(keyboard_strip PUBLIC src/vst) +add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp) +target_include_directories(keyboard_strip PUBLIC src) target_link_libraries(keyboard_strip PUBLIC editor_geometry) # waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta @@ -851,8 +851,8 @@ target_link_libraries(keyboard_strip PUBLIC editor_geometry) # (draggable start + loop markers over the picked capture's decoded PCM). The mirror of # keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample # alias the snap scans. NEITHER SDK. -add_library(waveform_view STATIC src/vst/waveform_view.cpp) -target_include_directories(waveform_view PUBLIC src/vst src) +add_library(waveform_view STATIC src/core/instrument/ui/waveform_view.cpp) +target_include_directories(waveform_view PUBLIC src) target_link_libraries(waveform_view PUBLIC editor_geometry peaks) # bank_sync (Phase S9/S8 reader) — PURE decision logic for the instrument's off-audio-thread @@ -862,8 +862,8 @@ target_link_libraries(waveform_view PUBLIC editor_geometry peaks) # setSelectedSampleId, component-state marker); this owns only the yes/no maths, unit-tested # outside the DAW. Links assignment_request for the decoded AssignmentRequest it consumes. # NEITHER SDK. -add_library(bank_sync STATIC src/vst/bank_sync.cpp) -target_include_directories(bank_sync PUBLIC src/vst src) +add_library(bank_sync STATIC src/core/instrument/map/bank_sync.cpp) +target_include_directories(bank_sync PUBLIC src) target_link_libraries(bank_sync PUBLIC assignment_request) target_link_libraries(bank_sync PRIVATE wire) @@ -872,15 +872,15 @@ target_link_libraries(bank_sync PRIVATE wire) # thumb-drag<->offset mapping, and the name-substring filter that composes with the bank # filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card # metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK. -add_library(browser_scroll STATIC src/vst/browser_scroll.cpp) -target_include_directories(browser_scroll PUBLIC src/vst) +add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp) +target_include_directories(browser_scroll PUBLIC src) target_link_libraries(browser_scroll PUBLIC capture_browser) # note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of # a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to # [0,127]). No dependency beyond the standard library. NEITHER SDK. -add_library(note_entry STATIC src/vst/note_entry.cpp) -target_include_directories(note_entry PUBLIC src/vst) +add_library(note_entry STATIC src/core/instrument/map/note_entry.cpp) +target_include_directories(note_entry PUBLIC src) # param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface # layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the @@ -888,16 +888,16 @@ target_include_directories(note_entry PUBLIC src/vst) # mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free # (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN # mapping. NEITHER SDK. -add_library(param_slider STATIC src/vst/param_slider.cpp) -target_include_directories(param_slider PUBLIC src/vst) +add_library(param_slider STATIC src/core/instrument/ui/param_slider.cpp) +target_include_directories(param_slider PUBLIC src) target_link_libraries(param_slider PUBLIC editor_geometry) # trigger_seam (Phase S-VIEW-3) — PURE Trigger-mode frames<->fraction converter for the TRIGGER # SEAM documented in envelope_overlay.h: triggerPlayLength / framesToFadeFraction / # fadeFractionToFrames. Owns the one formula so pack (draw) and unpack (commit) are provably # consistent. No shell/LICE/REAPER types — only . NEITHER SDK. -add_library(trigger_seam STATIC src/vst/trigger_seam.cpp) -target_include_directories(trigger_seam PUBLIC src/vst) +add_library(trigger_seam STATIC src/core/instrument/map/trigger_seam.cpp) +target_include_directories(trigger_seam PUBLIC src) # envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view # envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock @@ -905,16 +905,16 @@ target_include_directories(trigger_seam PUBLIC src/vst) # The mirror of waveform_view / param_slider; links editor_geometry for the shared Rect. # Deliberately engine-free (no sample_map / sampler_core) — the shell packs the zone's stored # AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK. -add_library(envelope_overlay STATIC src/vst/envelope_overlay.cpp) -target_include_directories(envelope_overlay PUBLIC src/vst) +add_library(envelope_overlay STATIC src/core/instrument/ui/envelope_overlay.cpp) +target_include_directories(envelope_overlay PUBLIC src) target_link_libraries(envelope_overlay PUBLIC editor_geometry) # envelope_edit (Phase S-VIEW-3) — PURE node hit-test + pixel-delta -> clamped-param inverse map # for the draggable envelope nodes: monotonic-in-time + range-clamped (against caller-supplied # slider maxima) so a drag can never produce a param a slider couldn't. The mirror of card_drag; # links envelope_overlay for the shared node vocabulary + the timeToX/levelToY maps. NEITHER SDK. -add_library(envelope_edit STATIC src/vst/envelope_edit.cpp) -target_include_directories(envelope_edit PUBLIC src/vst) +add_library(envelope_edit STATIC src/core/instrument/ui/envelope_edit.cpp) +target_include_directories(envelope_edit PUBLIC src) target_link_libraries(envelope_edit PUBLIC envelope_overlay) # knob_deck (Wave B FB1, r11) — PURE knob-deck layout + hit-test for the recomposed Sample face: @@ -922,23 +922,23 @@ target_link_libraries(envelope_edit PUBLIC envelope_overlay) # toggle), deterministic whole-group wrap, point -> control-id hit-test. The mirror of # action_bar / param_slider; links editor_geometry for the shared Rect. Engine-free — cells and # toggles carry opaque shell-owned control ids. NEITHER SDK. -add_library(knob_deck STATIC src/vst/knob_deck.cpp) -target_include_directories(knob_deck PUBLIC src/vst) +add_library(knob_deck STATIC src/core/instrument/ui/knob_deck.cpp) +target_include_directories(knob_deck PUBLIC src) target_link_libraries(knob_deck PUBLIC editor_geometry) # curve_popup (Wave B FB1, r11) — PURE centered-sheet geometry for the velocity-curve popup # editor: size clamps (60%/55% of window, 360..520 x 260..380), title row + close button, the # curve-box border rect, and the outside-sheet dismissal test. The mirror of overflow_menu; # links editor_geometry for the shared Rect. NEITHER SDK. -add_library(curve_popup STATIC src/vst/curve_popup.cpp) -target_include_directories(curve_popup PUBLIC src/vst) +add_library(curve_popup STATIC src/core/instrument/ui/curve_popup.cpp) +target_include_directories(curve_popup PUBLIC src) target_link_libraries(curve_popup PUBLIC editor_geometry) # master_gain (Wave B FB1) — PURE dB<->linear<->knob-taper math for the post-mixer master gain # (-inf..+24 dB; norm 0 = TRUE zero linear). One formula shared by the editor's Gain knob, the # v8 component-state wire cap, and the processor's applied gain. Standard library only. NEITHER SDK. -add_library(master_gain STATIC src/vst/master_gain.cpp) -target_include_directories(master_gain PUBLIC src/vst) +add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp) +target_include_directories(master_gain PUBLIC src) add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) @@ -1048,40 +1048,40 @@ set(LICE_SRC ) add_library(reaper_reasampler MODULE - src/main.cpp - src/capture.cpp - src/capture_realtime.cpp - src/realtime_record.cpp + src/app/main.cpp + src/shell/capture/capture.cpp + src/shell/capture/capture_realtime.cpp + src/core/capture/realtime_record.cpp src/persist.cpp src/bank_panel.cpp - src/draw_kit.cpp - src/mode_switch.cpp - src/tab_strip.cpp - src/insert.cpp - src/insert_plan.cpp + src/shell/panel/draw_kit.cpp + src/core/view/mode_switch.cpp + src/core/ui/tab_strip.cpp + src/shell/capture/insert.cpp + src/core/capture/insert_plan.cpp ${LICE_SRC} - src/view_mode_model.cpp - src/view_tree.cpp - src/view.cpp - src/track_guid.cpp - src/provenance_shell.cpp - src/guid_diff.cpp - src/lane_keys.cpp - src/item_read.cpp + src/core/view/view_mode_model.cpp + src/core/view/view_tree.cpp + src/shell/view/view.cpp + src/shell/capture/track_guid.cpp + src/shell/capture/provenance_shell.cpp + src/core/view/guid_diff.cpp + src/core/view/lane_keys.cpp + src/shell/capture/item_read.cpp src/actions.cpp src/ingest.cpp - src/bank_book.cpp - src/owned_manifest.cpp - src/drag_out_win.cpp - src/instrument_drop_win.cpp - src/action_bar.cpp - src/footer_bar.cpp - src/overflow_menu.cpp - src/mode_enable.cpp - src/tooltip.cpp - src/card_meta.cpp - src/card_drag.cpp - src/usage_scan.cpp + src/core/model/bank_book.cpp + src/core/model/owned_manifest.cpp + src/shell/actions/drag_out_win.cpp + src/shell/actions/instrument_drop_win.cpp + src/core/ui/action_bar.cpp + src/core/ui/footer_bar.cpp + src/core/ui/overflow_menu.cpp + src/core/ui/mode_enable.cpp + src/core/ui/tooltip.cpp + src/core/ui/card_meta.cpp + src/core/ui/card_drag.cpp + src/shell/persist/usage_scan.cpp ) target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest 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) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) @@ -1186,16 +1186,16 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # --- 5b) The VST3 module (loadable .vst3 DLL). ------------------------------- add_library(reasampler_vst MODULE - src/vst/vst_entry.cpp + src/shell/instrument/vst_entry.cpp src/vst/reasampler_processor.cpp src/vst/reasampler_editor.cpp - src/vst/reasampler_embed.cpp - src/vst/reaper_bridge.cpp + src/shell/instrument/reasampler_embed.cpp + src/shell/instrument/reaper_bridge.cpp # The Phase L (L1) draw kit — the ONE source of drawing the editor + embed shells # now consume (L3). Compiled into the MODULE (not a static lib) for the same reason # bank_panel does: it is the only kit TU touching LICE, and its cached-font engine # (LICE_CachedFont) needs the LICE_SRC TUs below linked into this artifact. - src/draw_kit.cpp + src/shell/panel/draw_kit.cpp # SDK module entry — compiled into the module (not the static lib) so the # InitDll/ExitDll dll exports survive the link (see vst3_sdk note above). ${VST3_SDK}/public.sdk/source/main/dllmain.cpp @@ -1248,7 +1248,10 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") knob_deck curve_popup master_gain sample_usage file_bytes) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. - target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) + # src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst: the + # two not-yet-split god TUs (reasampler_editor / reasampler_processor, Q-W2v) still + # live there and are included flat by the shell TUs. + target_include_directories(reasampler_vst PRIVATE src src/vst ${SDK_INC} ${WDL_INC}) # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk # product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to # pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set diff --git a/src/actions.cpp b/src/actions.cpp index 185afea..6f025b3 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // actions.cpp — the Design View action family (Phase D4). See actions.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -25,16 +26,16 @@ #include #include -#include "app_version.h" // channelCommandId / channelActionName — one channel-identity point +#include "core/version/app_version.h" // channelCommandId / channelActionName — one channel-identity point -#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) +#include "core/model/bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) #include "bank_panel.h" // selection seam + full-height toggles (B3/B4) -#include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) -#include "lane_keys.h" // isOnManualLane — the single managed/manual predicate +#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) +#include "core/view/lane_keys.h" // isOnManualLane — the single managed/manual predicate #include "persist.h" // ReaSamplerSession (owns book() + view() model) -#include "track_guid.h" // shared MediaTrack* -> canonical GUID key -#include "view.h" // applyMode + mintManagedLanes (D2 shell) -#include "view_mode_model.h" +#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key +#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) +#include "core/view/view_mode_model.h" #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) @@ -431,7 +432,7 @@ void designViewUnregisterActions(reaper_plugin_info_t* rec) { // // REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index // return a reference INTO the book's internal vector, which a create/delete can -// reallocate. No handler here caches a BankIndex& (or a Bank*) across a structural +// reallocate. No handler here caches a BankModel& (or a Bank*) across a structural // mutation — each resolves ids to strings up front and re-resolves after any // create/delete. Move/copy pass ids (not references) straight to moveSample/copySample. @@ -720,7 +721,7 @@ void doBankTransferSelected(bool copy) { return; } // Source = the bank the selection lives in (the focused region's displayed bank). - // Pass ids by value — no BankIndex& is cached across the loop's mutations. + // Pass ids by value — no BankModel& is cached across the loop's mutations. const std::string srcId = bankPanelSelectedSourceBankId(); if (srcId == destId) { ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); @@ -788,7 +789,7 @@ void doBankRemoveSelected() { return; } - // Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached + // Perform the removes (this-bank scope). Pass ids by value — no BankModel& is cached // across the loop's mutations. Count real drops so the no-op guardrail can skip the // undo point when nothing was removed (every id was already absent). int removed = 0; diff --git a/src/actions.h b/src/actions.h index e9d9bfe..492f0e5 100644 --- a/src/actions.h +++ b/src/actions.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // actions — the Design View action family (Phase D4). Registers the bindable // actions that drive the mode workflow and wires them end-to-end: toggle/activate diff --git a/src/main.cpp b/src/app/main.cpp similarity index 99% rename from src/main.cpp rename to src/app/main.cpp index 668eba4..3c5aafb 100644 --- a/src/main.cpp +++ b/src/app/main.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // // This file is the entire contract between REAPER and the extension: @@ -27,19 +28,19 @@ #include #include "actions.h" -#include "app_version.h" -#include "bank_model.h" +#include "core/version/app_version.h" +#include "core/model/bank_model.h" #include "bank_panel.h" -#include "batch_capture.h" -#include "capture.h" +#include "core/capture/batch_capture.h" +#include "shell/capture/capture.h" #include "ingest.h" -#include "insert.h" +#include "shell/capture/insert.h" #include "persist.h" -#include "provenance.h" -#include "provenance_shell.h" -#include "render_settings.h" -#include "track_guid.h" -#include "view.h" +#include "core/model/provenance.h" +#include "shell/capture/provenance_shell.h" +#include "core/capture/render_settings.h" +#include "shell/capture/track_guid.h" +#include "shell/view/view.h" #include // project-dir derivation for provenance parent resolution @@ -175,7 +176,7 @@ static int g_cmdCancelRealtime = 0; // window). The user copies this line into a bug report. static int g_cmdShowVersion = 0; -// The persistence session (M4): owns the in-memory BankIndex and bridges it to +// The persistence session (M4): owns the in-memory BankModel and bridges it to // project ext state. A timer tick drives g_session.poll() to detect project // load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to // the ACTIVE bank's index inside the session's BankBook; after a capture we serialize diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 5a0b618..94d6fd2 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // bank_panel.cpp — REAPER-facing docked grid (M5 Wave A/B + Phase B4). See // bank_panel.h. // @@ -32,7 +33,7 @@ // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, -// so a BankIndex& / Bank* must NEVER be cached across one. Every handler below +// so a BankModel& / Bank* must NEVER be cached across one. Every handler below // resolves fresh AFTER any mutation and passes bank IDS (not references) into the // model ops. @@ -47,39 +48,39 @@ #include #include -#include "action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2) +#include "core/ui/action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2) #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) -#include "drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) -#include "drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) -#include "app_version.h" // channelCommandId — compose the named-command lookup string (M11) -#include "bank_book.h" -#include "bank_grid.h" -#include "bank_model.h" -#include "card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test -#include "card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure) -#include "capture_paths.h" -#include "component_geometry.h" // KitBox — the kit text()'s draw box (L1) -#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) -#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) -#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) +#include "core/ui/drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) +#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) +#include "core/version/app_version.h" // channelCommandId — compose the named-command lookup string (M11) +#include "core/model/bank_book.h" +#include "core/ui/bank_grid.h" +#include "core/model/bank_model.h" +#include "core/ui/card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test +#include "core/ui/card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure) +#include "core/capture/capture_paths.h" +#include "core/ui/component_geometry.h" // KitBox — the kit text()'s draw box (L1) +#include "shell/panel/draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) +#include "core/ui/footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) +#include "core/view/guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) #include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest -#include "instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17) -#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) -#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) -#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) -#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) -#include "mode_switch.h" -#include "overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) -#include "peaks.h" +#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17) +#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) +#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) +#include "core/view/lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) +#include "core/ui/mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) +#include "core/view/mode_switch.h" +#include "core/ui/overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) +#include "core/audio/peaks.h" #include "persist.h" -#include "prune_button.h" // footer prune-button layout + hit-test (pure, R3) -#include "tooltip.h" // tooltip placement + prefix-strip (pure, L5) -#include "render_settings.h" // captureActionTable — the table-driven button rows (M11) -#include "tab_strip.h" -#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) -#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) -#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires -#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) +#include "core/ui/prune_button.h" // footer prune-button layout + hit-test (pure, R3) +#include "core/ui/tooltip.h" // tooltip placement + prefix-strip (pure, L5) +#include "core/capture/render_settings.h" // captureActionTable — the table-driven button rows (M11) +#include "core/ui/tab_strip.h" +#include "core/capture/tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) +#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) +#include "shell/view/view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires +#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) // SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); // on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). @@ -390,10 +391,10 @@ std::string currentProjectDir() { BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } -// The BankIndex a region currently displays. Pool region -> the pool; banks region -> +// The BankModel a region currently displays. Pool region -> the pool; banks region -> // the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved // FRESH every call (never cached across a mutation). -const BankIndex* indexForRegion(Region r) { +const BankModel* indexForRegion(Region r) { BankBook* b = book(); if (!b) return nullptr; if (r == Region::Pool) return &b->pool().index; @@ -1256,7 +1257,7 @@ RECT createBtnRect(const RECT& region) { // --- L7 slot-order display bridge --------------------------------------------- // // L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted -// SlotMap order (sparse, gap-preserving), NOT BankIndex insertion order. This one helper +// SlotMap order (sparse, gap-preserving), NOT BankModel insertion order. This one helper // is the single place that resolves a region's display, composed purely from bank_book's // slot order (orderedSampleIds) + card_drag's sparse slot rects (computeSlotRects) — the // shell adds no layout math of its own. @@ -1337,7 +1338,7 @@ RegionDisplay focusedDisplay() { // viewport. `selectionOwner` is true when this region holds the live selection, so // its cells show selection/focus chrome; the other region draws plain. void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, - const BankIndex* index, const std::string& emptyMsg, + const BankModel* index, const std::string& emptyMsg, bool selectionOwner, const std::string& projectDir, Region reg) { const RECT grid = regionGridRect(region, isBanks); if (grid.bottom <= grid.top) return; @@ -1348,7 +1349,7 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, } // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense - // BankIndex insertion order. Selection/focus are keyed by the occupied-ordinal (selection + // BankModel insertion order. Selection/focus are keyed by the occupied-ordinal (selection // space); a slot maps back to its ordinal via selectionForSlot. const RegionDisplay disp = regionDisplay(region, isBanks, reg); // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's @@ -1697,7 +1698,7 @@ bool refreshFingerprint() { stopAudition(); } reconcileShownBank(); - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; return true; } @@ -1926,11 +1927,11 @@ void deinitPreview() { // Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. // L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw -// BankIndex position. +// BankModel position. void startAudition(int idx) { stopAudition(); - const BankIndex* index = indexForRegion(g_panel.focusedRegion); + const BankModel* index = indexForRegion(g_panel.focusedRegion); if (!index) return; const RegionDisplay disp = focusedDisplay(); if (idx < 0 || idx >= disp.occupiedCount()) return; @@ -1978,7 +1979,7 @@ void invalidatePanel() { // exactly one occupied slot (gaps are empty slots, which the index never backs), so the // raw index size IS the dense selection-space extent. int focusedItemCount() { - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); return idx ? static_cast(idx->size()) : 0; } @@ -2006,7 +2007,7 @@ bool regionAt(int x, int y, Region& out) { // --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- // // Each op mutates g_session.book() then persists via persistBankOp(). After a -// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we +// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we // resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an // unsaved project the empty-close discard in persistBankOp ensures no stale state // survives (matches the capture/B3 quiet-persist idiom). @@ -2123,7 +2124,7 @@ void doActivateBank(const std::string& bankId) { } // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass -// ids straight to the model op (no BankIndex& cached across the loop's mutations). +// ids straight to the model op (no BankModel& cached across the loop's mutations). // // NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): // * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally @@ -2166,7 +2167,7 @@ void transferSamples(const std::vector& sampleIds, // the file: a last-reference remove leaves the file on disk, orphaned until Phase R // prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent // (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — -// one Ctrl-Z restores the index entry. Ids passed by value — no BankIndex& cached +// one Ctrl-Z restores the index entry. Ids passed by value — no BankModel& cached // across the loop's mutations. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { @@ -2190,7 +2191,7 @@ void removeSamples(const std::vector& sampleIds, // The selection's sample ids resolved against the FOCUSED region's bank (source of a // move/copy). Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { - // L7: selection ordinals index the DISPLAY (slot) order, not BankIndex insertion order. + // L7: selection ordinals index the DISPLAY (slot) order, not BankModel insertion order. // orderedIds[i] is the id at selection ordinal i. std::vector ids; const RegionDisplay disp = focusedDisplay(); @@ -2212,7 +2213,7 @@ std::vector resolveDragPathsForOs() { std::vector resolved; BankBook* b = book(); if (!b) return {}; - const BankIndex* idx = b->index(g_panel.dragSourceBankId); + const BankModel* idx = b->index(g_panel.dragSourceBankId); if (!idx) return {}; const std::string projectDir = currentProjectDir(); @@ -3138,7 +3139,7 @@ void onLBtnUp(int x, int y) { // collapses the multi-selection to the pressed cell (standard behavior). // Release capture acquired at arm time (handleClick) — drag never started. if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); const int count = idx ? static_cast(idx->size()) : 0; const int focus = g_panel.selection.focus; if (focus >= 0) diff --git a/src/bank_panel.h b/src/bank_panel.h index 8a3b5c8..0139852 100644 --- a/src/bank_panel.h +++ b/src/bank_panel.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns // a SWELL dialog docked via DockWindowAddEx, and paints the current project's @@ -13,7 +14,7 @@ #include #include -#include "tail_control.h" // TailSetting — the panel's tail-mode toggle state +#include "core/capture/tail_control.h" // TailSetting — the panel's tail-mode toggle state namespace reasampler { diff --git a/src/peaks.cpp b/src/core/audio/peaks.cpp similarity index 98% rename from src/peaks.cpp rename to src/core/audio/peaks.cpp index b2a695a..3c4f03a 100644 --- a/src/peaks.cpp +++ b/src/core/audio/peaks.cpp @@ -1,4 +1,4 @@ -#include "peaks.h" +#include "core/audio/peaks.h" #include #include @@ -14,7 +14,7 @@ // extra frames) with no rounding drift and no dropped tail — the last bin's end is // always exactly frameCount. -namespace reasampler { +namespace reasampler::audio { Envelope computeEnvelope(const std::vector& interleaved, std::size_t channelCount, @@ -123,4 +123,4 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, return kNoFrameAboveThreshold; } -} // namespace reasampler +} // namespace reasampler::audio \ No newline at end of file diff --git a/src/peaks.h b/src/core/audio/peaks.h similarity index 99% rename from src/peaks.h rename to src/core/audio/peaks.h index dc1365a..f1e1ee9 100644 --- a/src/peaks.h +++ b/src/core/audio/peaks.h @@ -11,7 +11,7 @@ #include #include -namespace reasampler { +namespace reasampler::audio { // Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer // format (its render/PCM_source callbacks hand back interleaved 32-bit float), so @@ -119,4 +119,4 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, std::size_t frameCount, AudioSample linearThreshold); -} // namespace reasampler +} // namespace reasampler::audio \ No newline at end of file diff --git a/src/batch_capture.cpp b/src/core/capture/batch_capture.cpp similarity index 96% rename from src/batch_capture.cpp rename to src/core/capture/batch_capture.cpp index 3e46851..1d85add 100644 --- a/src/batch_capture.cpp +++ b/src/core/capture/batch_capture.cpp @@ -1,11 +1,11 @@ // batch_capture.cpp — pure logic for M11 batch capture. See header. // NO REAPER types; unit-tested by tests/test_batch_capture.cpp. -#include "batch_capture.h" +#include "core/capture/batch_capture.h" #include -namespace reasampler { +namespace reasampler::capture { std::vector planCaptureUnits(const std::vector& ranges) { std::vector units; @@ -73,4 +73,4 @@ std::string BatchOutcome::summaryLine(const std::string& noun) const { return line; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/batch_capture.h b/src/core/capture/batch_capture.h similarity index 98% rename from src/batch_capture.h rename to src/core/capture/batch_capture.h index 213eb9e..9ad5084 100644 --- a/src/batch_capture.h +++ b/src/core/capture/batch_capture.h @@ -29,7 +29,7 @@ #include #include -namespace reasampler { +namespace reasampler::capture { // One capture in a batch: an exact source range plus its 1-based ordinal within the // KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's @@ -97,4 +97,4 @@ private: std::vector results_; }; -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/capture_paths.cpp b/src/core/capture/capture_paths.cpp similarity index 99% rename from src/capture_paths.cpp rename to src/core/capture/capture_paths.cpp index 33bfbce..f7dc629 100644 --- a/src/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -1,4 +1,4 @@ -#include "capture_paths.h" +#include "core/capture/capture_paths.h" #include #include @@ -8,7 +8,7 @@ #include #include -namespace reasampler { +namespace reasampler::capture { std::string hashBytes(const std::uint8_t* data, std::size_t len) { // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. @@ -284,4 +284,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, return ProjectTransition::NoOp; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/capture_paths.h b/src/core/capture/capture_paths.h similarity index 98% rename from src/capture_paths.h rename to src/core/capture/capture_paths.h index 4ab8026..8c92268 100644 --- a/src/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -17,7 +17,7 @@ #include #include -namespace reasampler { +namespace reasampler::capture { // The project-relative bank subfolder. All captured wavs live here so the bank // travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank). @@ -25,7 +25,7 @@ inline constexpr const char* kBankSubfolder = "reasampler_bank"; // A resolved pair of paths for one capture: where REAPER must be told to write // (absolute, because RENDER_FILE wants a directory REAPER can create/open) and -// what we store in the BankIndex (project-relative, because the index is +// what we store in the BankModel (project-relative, because the index is // relative-paths-only — CLAUDE.md precision invariant). struct BankPaths { std::string absoluteDir; // /reasampler_bank (forward slash) @@ -87,7 +87,7 @@ std::string sanitizeStem(const std::string& baseName); // timestamp or counter) so repeated captures do not collide. // Also sanitized. May be empty. // Produces "[_].wav". The relativePath is always project-relative and -// forward-slashed so it satisfies BankIndex::add's relative-only invariant. +// forward-slashed so it satisfies BankModel::add's relative-only invariant. BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); @@ -212,4 +212,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& currentGuid, const std::string& currentPath); -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/insert_plan.cpp b/src/core/capture/insert_plan.cpp similarity index 93% rename from src/insert_plan.cpp rename to src/core/capture/insert_plan.cpp index 9c3983e..d9590de 100644 --- a/src/insert_plan.cpp +++ b/src/core/capture/insert_plan.cpp @@ -1,8 +1,8 @@ // insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic. -#include "insert_plan.h" +#include "core/capture/insert_plan.h" -namespace reasampler { +namespace reasampler::capture { namespace { @@ -46,4 +46,4 @@ int computeInsertMode(const InsertOptions& opts) { return mode; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/insert_plan.h b/src/core/capture/insert_plan.h similarity index 98% rename from src/insert_plan.h rename to src/core/capture/insert_plan.h index 7a6e2cb..a3b9770 100644 --- a/src/insert_plan.h +++ b/src/core/capture/insert_plan.h @@ -22,7 +22,7 @@ #include -namespace reasampler { +namespace reasampler::capture { // Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3). // We expose only the two placement targets M6 needs; "add as takes" (3) is a @@ -72,4 +72,4 @@ int computeInsertMode(const InsertOptions& opts); // any computed mode (the "no silent time-stretch" invariant, made checkable). inline constexpr int kStretchToTimeSelBit = 4; -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/realtime_record.cpp b/src/core/capture/realtime_record.cpp similarity index 98% rename from src/realtime_record.cpp rename to src/core/capture/realtime_record.cpp index 9b287d1..8a42296 100644 --- a/src/realtime_record.cpp +++ b/src/core/capture/realtime_record.cpp @@ -1,9 +1,9 @@ // realtime_record.cpp — pure logic for the realtime-record backend (M8). See header. // NO REAPER types; unit-tested by tests/test_realtime_record.cpp. -#include "realtime_record.h" +#include "core/capture/realtime_record.h" -namespace reasampler { +namespace reasampler::capture { RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { RecordModePlan p; @@ -124,4 +124,4 @@ bool isTerminalPhase(RecordPhase phase) { return phase == RecordPhase::Done || phase == RecordPhase::Failed; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/realtime_record.h b/src/core/capture/realtime_record.h similarity index 98% rename from src/realtime_record.h rename to src/core/capture/realtime_record.h index 82fec4c..3ab269b 100644 --- a/src/realtime_record.h +++ b/src/core/capture/realtime_record.h @@ -24,9 +24,13 @@ #include #include -#include "bank_model.h" // Sample, SourceMode (pure) +#include "core/model/bank_model.h" // Sample, SourceMode (pure) -namespace reasampler { +namespace reasampler::capture { + +using model::Sample; +using model::Tier; +using model::SourceMode; // --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- // @@ -231,4 +235,4 @@ bool isStopRequested(RecordPhase phase); // Only Done and Failed are terminal; Recording and Finalizing are live. bool isTerminalPhase(RecordPhase phase); -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/render_settings.cpp b/src/core/capture/render_settings.cpp similarity index 98% rename from src/render_settings.cpp rename to src/core/capture/render_settings.cpp index 041cfa9..d73ff32 100644 --- a/src/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -1,13 +1,13 @@ // render_settings.cpp — pure logic for the three-scope capture action family. See header. // NO REAPER types; unit-tested by tests/test_render_settings.cpp. -#include "render_settings.h" +#include "core/capture/render_settings.h" #include #include #include -namespace reasampler { +namespace reasampler::capture { double autoTrimEndRatio() { // Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is @@ -221,4 +221,4 @@ const std::vector& captureActionTable() { return table; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/render_settings.h b/src/core/capture/render_settings.h similarity index 98% rename from src/render_settings.h rename to src/core/capture/render_settings.h index 8d337eb..75f8e30 100644 --- a/src/render_settings.h +++ b/src/core/capture/render_settings.h @@ -25,9 +25,11 @@ #include #include -#include "bank_model.h" // SourceMode (pure enum) +#include "core/model/bank_model.h" // SourceMode (pure enum) -namespace reasampler { +namespace reasampler::capture { + +using model::SourceMode; // --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) -- // @@ -260,4 +262,4 @@ struct CaptureActionDef { // capture applies is read from the docked-panel setting, not baked into the row. const std::vector& captureActionTable(); -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/tail_control.cpp b/src/core/capture/tail_control.cpp similarity index 97% rename from src/tail_control.cpp rename to src/core/capture/tail_control.cpp index 2683e2e..1ee105d 100644 --- a/src/tail_control.cpp +++ b/src/core/capture/tail_control.cpp @@ -1,13 +1,13 @@ // tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor. -#include "tail_control.h" +#include "core/capture/tail_control.h" #include #include #include "core/json/json.h" -namespace reasampler { +namespace reasampler::capture { TailMode cycleTailMode(TailMode current) { switch (current) { @@ -128,4 +128,4 @@ std::optional deserializeTailSetting(const std::string& blob) { return out; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/tail_control.h b/src/core/capture/tail_control.h similarity index 95% rename from src/tail_control.h rename to src/core/capture/tail_control.h index 1e7ab93..2ea3855 100644 --- a/src/tail_control.h +++ b/src/core/capture/tail_control.h @@ -12,9 +12,9 @@ #include #include -#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract +#include "core/capture/render_settings.h" // TailMode (pure enum) — the three-state tail contract -namespace reasampler { +namespace reasampler::capture { // The Manual-mode starting length. 2 s is a musically useful default tail (a bar of // reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a @@ -67,4 +67,4 @@ std::string tailToggleLabel(const TailSetting& setting); std::string serializeTailSetting(const TailSetting& setting); std::optional deserializeTailSetting(const std::string& json); -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/wav_trim.cpp b/src/core/capture/wav_trim.cpp similarity index 98% rename from src/wav_trim.cpp rename to src/core/capture/wav_trim.cpp index e8265ae..5c4d89b 100644 --- a/src/wav_trim.cpp +++ b/src/core/capture/wav_trim.cpp @@ -1,10 +1,10 @@ // wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor. -#include "wav_trim.h" +#include "core/capture/wav_trim.h" #include // std::memcpy, std::memcmp -namespace reasampler { +namespace reasampler::capture { namespace { @@ -157,4 +157,4 @@ WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) return plan; } -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/wav_trim.h b/src/core/capture/wav_trim.h similarity index 97% rename from src/wav_trim.h rename to src/core/capture/wav_trim.h index 7acf5ee..f47eea2 100644 --- a/src/wav_trim.h +++ b/src/core/capture/wav_trim.h @@ -29,9 +29,11 @@ #include #include -#include "peaks.h" // AudioSample (float) +#include "core/audio/peaks.h" // AudioSample (float) -namespace reasampler { +namespace reasampler::capture { + +using audio::AudioSample; // The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the // bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field @@ -98,4 +100,4 @@ struct WavTruncatePlan { // truncate the file to newFileByteLength. WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); -} // namespace reasampler +} // namespace reasampler::capture \ No newline at end of file diff --git a/src/vst/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp similarity index 85% rename from src/vst/master_gain.cpp rename to src/core/instrument/engine/master_gain.cpp index 00413e4..4fac41a 100644 --- a/src/vst/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -1,17 +1,17 @@ // master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes. -#include "master_gain.h" +#include "core/instrument/engine/master_gain.h" + +#include "core/util/clamp01.h" #include #include #include #include -namespace reasampler::vst { +namespace reasampler::instrument::engine { -namespace { -double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } -} // namespace +using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); } @@ -48,4 +48,4 @@ void formatMasterGainLabel(double norm, char* buf, std::size_t len) { std::snprintf(buf, len, "%+.1fdB", db); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine \ No newline at end of file diff --git a/src/vst/master_gain.h b/src/core/instrument/engine/master_gain.h similarity index 97% rename from src/vst/master_gain.h rename to src/core/instrument/engine/master_gain.h index 9ba20aa..dce0d67 100644 --- a/src/vst/master_gain.h +++ b/src/core/instrument/engine/master_gain.h @@ -19,7 +19,7 @@ #include -namespace reasampler::vst { +namespace reasampler::instrument::engine { // The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the // finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1. @@ -55,4 +55,4 @@ double masterGainNormFromLinear(double linear); // including the terminator. Pure. void formatMasterGainLabel(double norm, char* buf, std::size_t len); -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine \ No newline at end of file diff --git a/src/vst/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp similarity index 99% rename from src/vst/pitch_shift.cpp rename to src/core/instrument/engine/pitch_shift.cpp index dd73d16..96df91f 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -20,13 +20,13 @@ // ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the // stream through with ZERO added latency; a silence-warmed one is a clean window delay. -#include "pitch_shift.h" +#include "core/instrument/engine/pitch_shift.h" #include #include #include -namespace reasampler { +namespace reasampler::instrument::engine { namespace { @@ -431,4 +431,4 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) return static_cast(out); } -} // namespace reasampler +} // namespace reasampler::instrument::engine \ No newline at end of file diff --git a/src/vst/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h similarity index 98% rename from src/vst/pitch_shift.h rename to src/core/instrument/engine/pitch_shift.h index aed90d6..d866826 100644 --- a/src/vst/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -66,9 +66,11 @@ #include #include -#include "peaks.h" // AudioSample (float) +#include "core/audio/peaks.h" // AudioSample (float) -namespace reasampler { +namespace reasampler::instrument::engine { + +using audio::AudioSample; // The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG // stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation @@ -225,4 +227,4 @@ private: // live fade by that rate. }; -} // namespace reasampler +} // namespace reasampler::instrument::engine \ No newline at end of file diff --git a/src/vst/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp similarity index 99% rename from src/vst/sampler_core.cpp rename to src/core/instrument/engine/sampler_core.cpp index c0920cd..0edf418 100644 --- a/src/vst/sampler_core.cpp +++ b/src/core/instrument/engine/sampler_core.cpp @@ -2,7 +2,7 @@ // contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, // voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. -#include "sampler_core.h" +#include "core/instrument/engine/sampler_core.h" #include @@ -270,7 +270,7 @@ bool Voice::sustainLoopUsable() const { } void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, - double keyTrack, const vst::VelocityCurve& velocityCurve, + double keyTrack, const VelocityCurve& velocityCurve, bool declickTakeover) { // Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT // REFERENCE — the last rendered output — and mark the compensation PENDING iff this diff --git a/src/vst/sampler_core.h b/src/core/instrument/engine/sampler_core.h similarity index 98% rename from src/vst/sampler_core.h rename to src/core/instrument/engine/sampler_core.h index c2c68bc..354845c 100644 --- a/src/vst/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -22,12 +22,19 @@ #include #include -#include "peaks.h" // AudioSample (float) -#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) -#include "velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) +#include "core/audio/peaks.h" // AudioSample (float) +#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) namespace reasampler { +// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core +// re-namespaces in its own split wave (Q-W2v). +using audio::AudioSample; +using instrument::engine::PitchShifter; +using instrument::engine::VelocityCurve; +using instrument::engine::VelocityPoint; + // The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 // downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders // per-channel. A PERFORMANCE choice the instrument owns (component state), never written @@ -232,7 +239,7 @@ struct KeyZone { // of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in // Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at // unity, a deliberate behavior change from the pre-r10 linear map. - vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); + VelocityCurve velocityCurve = VelocityCurve::flat(); std::size_t sampleIndex = 0; // index into Keymap::samples }; @@ -439,7 +446,7 @@ public: // kDeclickDecay). A fresh start never declicks. void start(int note, int velocity, const SampleData& sample, int rootNote, double keyTrack = 1.0, - const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat(), + const VelocityCurve& velocityCurve = VelocityCurve::flat(), bool declickTakeover = false); // MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the diff --git a/src/vst/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp similarity index 97% rename from src/vst/velocity_curve.cpp rename to src/core/instrument/engine/velocity_curve.cpp index 248184d..868073a 100644 --- a/src/vst/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -1,23 +1,18 @@ // velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types. -#include "velocity_curve.h" +#include "core/instrument/engine/velocity_curve.h" #include // std::max, std::min, std::abs, std::stable_sort #include // std::fabs #include // std::move -namespace reasampler::vst { +namespace reasampler::instrument::engine { namespace { -double clamp(double v, double lo, double hi) { - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} -double clampVelocity(double v) { return clamp(v, kVelMin, kVelMax); } -double clampAmp(double a) { return clamp(a, kAmpMin, kAmpMax); } +double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); } +double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); } // Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y // spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward). @@ -203,7 +198,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl // Interior point: clamp X strictly within its immediate neighbours so it can't cross them. const double lo = points_[index - 1].velocity; const double hi = points_[index + 1].velocity; - newVel = clamp(clampVelocity(velocity), lo, hi); + newVel = std::clamp(clampVelocity(velocity), lo, hi); } points_[index] = VelocityPoint{newVel, newAmp}; return points_[index]; @@ -274,4 +269,4 @@ bool VelocityCurve::equals(const VelocityCurve& other, double eps) const { return true; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine \ No newline at end of file diff --git a/src/vst/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h similarity index 99% rename from src/vst/velocity_curve.h rename to src/core/instrument/engine/velocity_curve.h index 6c761e9..5d1bb27 100644 --- a/src/vst/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -38,7 +38,7 @@ // Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's // role, but one layer lower, so the coupling stays out of the engine core. -namespace reasampler::vst { +namespace reasampler::instrument::engine { // The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into. inline constexpr double kVelMin = 0.0; @@ -168,4 +168,4 @@ private: std::vector points_; }; -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine \ No newline at end of file diff --git a/src/vst/bank_sync.cpp b/src/core/instrument/map/bank_sync.cpp similarity index 95% rename from src/vst/bank_sync.cpp rename to src/core/instrument/map/bank_sync.cpp index aff6c6b..b88eea7 100644 --- a/src/vst/bank_sync.cpp +++ b/src/core/instrument/map/bank_sync.cpp @@ -1,13 +1,13 @@ // bank_sync.cpp — see bank_sync.h. Pure; standard library only. -#include "bank_sync.h" +#include "core/instrument/map/bank_sync.h" #include #include #include "core/wire/wire.h" -namespace reasampler::vst { +namespace reasampler::instrument::map { std::int64_t parseBankGeneration(const std::string& raw) { // Whole-string, non-negative decimal parse WITHOUT exceptions or locale @@ -60,4 +60,4 @@ AssignConsumeDecision consumeDecision(const std::optional& re return d; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/bank_sync.h b/src/core/instrument/map/bank_sync.h similarity index 96% rename from src/vst/bank_sync.h rename to src/core/instrument/map/bank_sync.h index cb1002d..acaf1ad 100644 --- a/src/vst/bank_sync.h +++ b/src/core/instrument/map/bank_sync.h @@ -21,9 +21,11 @@ #include #include -#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes) +#include "core/wire/assignment_request.h" // AssignmentRequest (the decoded request this consumes) -namespace reasampler::vst { +namespace reasampler::instrument::map { + +using wire::AssignmentRequest; // The S9 bank-generation "generation 0 = never stamped" default. A project saved before // S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value @@ -102,4 +104,4 @@ AssignConsumeDecision consumeDecision(const std::optional& re std::int64_t lastConsumed, bool resolves, bool isFocusedTarget); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/bridge_marshal.cpp b/src/core/instrument/map/bridge_marshal.cpp similarity index 80% rename from src/vst/bridge_marshal.cpp rename to src/core/instrument/map/bridge_marshal.cpp index 23d908c..3414c34 100644 --- a/src/vst/bridge_marshal.cpp +++ b/src/core/instrument/map/bridge_marshal.cpp @@ -1,8 +1,8 @@ // bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types. -#include "bridge_marshal.h" +#include "core/instrument/map/bridge_marshal.h" -namespace reasampler::vst { +namespace reasampler::instrument::map { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer) { @@ -13,4 +13,4 @@ std::optional decodeGetProjExtState(int apiReturn, return buffer; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h similarity index 95% rename from src/vst/bridge_marshal.h rename to src/core/instrument/map/bridge_marshal.h index 2c75068..3e432dd 100644 --- a/src/vst/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -22,7 +22,7 @@ #include #include -namespace reasampler::vst { +namespace reasampler::instrument::map { // Interpret a GetProjExtState result: the int return value (bytes the API reports for // the key) and the buffer it filled. Returns the value only when the API reported a @@ -34,4 +34,4 @@ namespace reasampler::vst { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/note_entry.cpp b/src/core/instrument/map/note_entry.cpp similarity index 96% rename from src/vst/note_entry.cpp rename to src/core/instrument/map/note_entry.cpp index 99cb354..3e936e3 100644 --- a/src/vst/note_entry.cpp +++ b/src/core/instrument/map/note_entry.cpp @@ -1,11 +1,11 @@ // note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry. -#include "note_entry.h" +#include "core/instrument/map/note_entry.h" #include #include -namespace reasampler::vst { +namespace reasampler::instrument::map { namespace { char asciiUpper(char c) { @@ -110,4 +110,4 @@ std::optional parseNoteEntry(const std::string& text) { return parseNoteName(s); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/note_entry.h b/src/core/instrument/map/note_entry.h similarity index 95% rename from src/vst/note_entry.h rename to src/core/instrument/map/note_entry.h index de3ae0a..07dbaf0 100644 --- a/src/vst/note_entry.h +++ b/src/core/instrument/map/note_entry.h @@ -22,7 +22,7 @@ #include #include -namespace reasampler::vst { +namespace reasampler::instrument::map { // Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal // integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is @@ -30,4 +30,4 @@ namespace reasampler::vst { // into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. std::optional parseNoteEntry(const std::string& text); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/sample_map.cpp b/src/core/instrument/map/sample_map.cpp similarity index 98% rename from src/vst/sample_map.cpp rename to src/core/instrument/map/sample_map.cpp index 959582d..7ae5813 100644 --- a/src/vst/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -1,7 +1,7 @@ // sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / // vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. -#include "sample_map.h" +#include "core/instrument/map/sample_map.h" #include // std::min #include // assert @@ -9,10 +9,12 @@ #include // std::memcpy #include // std::move -#include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap namespace reasampler { +using instrument::engine::masterGainMaxLinear; + namespace { // Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank @@ -544,9 +546,9 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putU64le(out, doubleToBits(z.keyTrack)); // PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE // control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included). - const std::vector& pts = z.velocityCurve.points(); + const std::vector& pts = z.velocityCurve.points(); putU32le(out, static_cast(pts.size())); - for (const reasampler::vst::VelocityPoint& p : pts) { + for (const VelocityPoint& p : pts) { putU64le(out, doubleToBits(p.velocity)); putU64le(out, doubleToBits(p.amp)); } @@ -642,7 +644,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { // false mid-curve) leaves the flat default and the mid-zone break below drops the rest. if (curveTail) { const std::uint32_t ptCount = r.u32(); - std::vector pts; + std::vector pts; // Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge // count can't trigger a giant allocation before the bounded reads fail — the loop still // stops on r.ok, this only caps the speculative reserve. @@ -651,9 +653,9 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { const double vel = bitsToDouble(r.u64()); const double amp = bitsToDouble(r.u64()); - pts.push_back(reasampler::vst::VelocityPoint{vel, amp}); + pts.push_back(VelocityPoint{vel, amp}); } - if (r.ok) z.velocityCurve = reasampler::vst::VelocityCurve::fromPoints(std::move(pts)); + if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts)); } // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. @@ -729,7 +731,7 @@ std::vector serializeComponentState(const ComponentState& state) { // negative falls back to unity; above the +24 dB cap clamps to the cap. { double g = state.masterGainLinear; - const double maxLin = vst::masterGainMaxLinear(); + const double maxLin = masterGainMaxLinear(); if (!std::isfinite(g) || g < 0.0) g = 1.0; if (g > maxLin) g = maxLin; putU64le(out, doubleToBits(g)); @@ -887,7 +889,7 @@ ComponentState deserializeComponentState(const std::vector& bytes, if (!r.ok) return out; // truncated inside the gain double — out already carries // mode/marker/velocity/voice fields from above; unity holds out.masterGainLinear = - (std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9)) + (std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9)) ? g : 1.0; } diff --git a/src/vst/sample_map.h b/src/core/instrument/map/sample_map.h similarity index 98% rename from src/vst/sample_map.h rename to src/core/instrument/map/sample_map.h index d8703b9..76764b6 100644 --- a/src/vst/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -23,12 +23,18 @@ #include #include -#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse) -#include "sampler_core.h" // Keymap, SampleData, SampleLoop -#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse) +#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) namespace reasampler { +// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map +// re-namespaces in its own split wave (Q-W2v). +using audio::AudioSample; +using instrument::engine::VelocityCurve; +using instrument::engine::VelocityPoint; + // The bank sample this instance is bound to, distilled from the live "banks" blob: // the project-relative WAV path the file seam must resolve+decode, plus the S2 bank // intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. @@ -287,7 +293,7 @@ struct PerformanceZone { // already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT // preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start. // Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7). - vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); + VelocityCurve velocityCurve = VelocityCurve::flat(); // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the @@ -341,7 +347,7 @@ struct ResolvedZone { int highNote = 127; int rootNote = 60; // effective: override, else bank intrinsic, else 60 double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET) - vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone + VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build) diff --git a/src/vst/trigger_seam.cpp b/src/core/instrument/map/trigger_seam.cpp similarity index 87% rename from src/vst/trigger_seam.cpp rename to src/core/instrument/map/trigger_seam.cpp index 0ee61a4..3bcc81b 100644 --- a/src/vst/trigger_seam.cpp +++ b/src/core/instrument/map/trigger_seam.cpp @@ -1,10 +1,10 @@ // trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h). -#include "trigger_seam.h" +#include "core/instrument/map/trigger_seam.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::map { std::int64_t triggerPlayLength(double lengthFraction, std::int64_t frameCount, @@ -24,4 +24,4 @@ std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) return static_cast(fadeFraction * static_cast(playLength) + 0.5); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/trigger_seam.h b/src/core/instrument/map/trigger_seam.h similarity index 97% rename from src/vst/trigger_seam.h rename to src/core/instrument/map/trigger_seam.h index d7ce3c3..483bb38 100644 --- a/src/vst/trigger_seam.h +++ b/src/core/instrument/map/trigger_seam.h @@ -25,7 +25,7 @@ #include -namespace reasampler::vst { +namespace reasampler::instrument::map { // The source-frame length of the Trigger played span: // postStart = max(0, frameCount - startFrame) @@ -48,4 +48,4 @@ double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); // Rounds to nearest integer frame. Returns 0 when playLength == 0. std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map \ No newline at end of file diff --git a/src/vst/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp similarity index 88% rename from src/vst/browser_scroll.cpp rename to src/core/instrument/ui/browser_scroll.cpp index 85be0f8..d643f1a 100644 --- a/src/vst/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -1,12 +1,12 @@ // browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 // capture_browser. No host types; only the shared Rect + BrowserLayout. -#include "browser_scroll.h" +#include "core/instrument/ui/browser_scroll.h" #include #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { // The minimum thumb height so a very long bank still yields a grabbable thumb. @@ -26,7 +26,7 @@ int scrollContentHeight(const BrowserLayout& layout, int cardCount) { int scrollMaxOffset(const BrowserLayout& layout, int cardCount) { const int content = scrollContentHeight(layout, cardCount); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); return (std::max)(0, content - gridH); } @@ -41,7 +41,7 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of VisibleRange vr; if (cardCount <= 0) return vr; const int columns = (std::max)(1, layout.columns); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); if (gridH <= 0 || kBrowserCardHeight <= 0) { vr.first = 0; vr.last = 0; @@ -66,21 +66,21 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) { Rect r = cardCellRect(layout, index); - if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty - return Rect{r.left, r.top - offset, r.right, r.bottom - offset}; + if (r.right() <= r.x && r.bottom() <= r.y) return r; // empty (negative index) stays empty + return Rect::ltrb(r.x, r.y - offset, r.right(), r.bottom() - offset); } Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { const int content = scrollContentHeight(layout, cardCount); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar const int maxOff = content - gridH; if (offset < 0) offset = 0; if (offset > maxOff) offset = maxOff; - const int trackRight = layout.grid.right; + const int trackRight = layout.grid.right(); const int trackLeft = trackRight - kScrollbarWidth; - const int trackTop = layout.grid.top; + const int trackTop = layout.grid.y; // Thumb height proportional to the visible fraction, floored at a grabbable minimum but // never taller than the track. @@ -95,13 +95,13 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { thumbTop = trackTop + static_cast( static_cast(offset) * trackSpan / maxOff); } - return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH}; + return Rect::ltrb(trackLeft, thumbTop, trackRight, thumbTop + thumbH); } int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels) { const int content = scrollContentHeight(layout, cardCount); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); // Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. @@ -124,7 +124,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse Rect searchBoxRect(int w) { if (w <= 0) return Rect{}; - return Rect{0, 0, w, kSearchBoxHeight}; + return Rect::ltrb(0, 0, w, kSearchBoxHeight); } bool nameMatchesQuery(const std::string& name, const std::string& query) { @@ -155,4 +155,4 @@ std::vector filterNameIndices(const std::vector& names, return out; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/browser_scroll.h b/src/core/instrument/ui/browser_scroll.h similarity index 97% rename from src/vst/browser_scroll.h rename to src/core/instrument/ui/browser_scroll.h index 81aa831..f04e88c 100644 --- a/src/vst/browser_scroll.h +++ b/src/core/instrument/ui/browser_scroll.h @@ -24,9 +24,9 @@ #include #include -#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect +#include "core/instrument/ui/capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell // draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so @@ -104,4 +104,4 @@ bool nameMatchesQuery(const std::string& name, const std::string& query); std::vector filterNameIndices(const std::vector& names, const std::string& query); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/capture_browser.cpp b/src/core/instrument/ui/capture_browser.cpp similarity index 68% rename from src/vst/capture_browser.cpp rename to src/core/instrument/ui/capture_browser.cpp index a36f63e..969b540 100644 --- a/src/vst/capture_browser.cpp +++ b/src/core/instrument/ui/capture_browser.cpp @@ -1,10 +1,10 @@ // capture_browser.cpp — see capture_browser.h. Pure math; no host types. -#include "capture_browser.h" +#include "core/instrument/ui/capture_browser.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -23,10 +23,10 @@ BrowserLayout layoutBrowser(int w, int h) { BrowserLayout out; const int tabH = std::min(kBrowserTabHeight, ch); - out.tabStrip = Rect{0, 0, cw, tabH}; - out.grid = Rect{0, tabH, cw, ch}; + out.tabStrip = Rect::ltrb(0, 0, cw, tabH); + out.grid = Rect::ltrb(0, tabH, cw, ch); - const int gridW = std::max(0, out.grid.width()); + const int gridW = std::max(0, out.grid.width); out.columns = std::max(1, gridW / kBrowserCardWidth); return out; } @@ -36,38 +36,38 @@ Rect cardCellRect(const BrowserLayout& layout, int index) { const int cols = std::max(1, layout.columns); const int col = index % cols; const int row = index / cols; - const int left = layout.grid.left + col * kBrowserCardWidth; - const int top = layout.grid.top + row * kBrowserCardHeight; - return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight}; + const int left = layout.grid.x + col * kBrowserCardWidth; + const int top = layout.grid.y + row * kBrowserCardHeight; + return Rect::ltrb(left, top, left + kBrowserCardWidth, top + kBrowserCardHeight); } Rect cardContentRect(const BrowserLayout& layout, int index) { if (index < 0) return Rect{}; const Rect cell = cardCellRect(layout, index); - return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter, - cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter}; + return Rect::ltrb(cell.x + kBrowserCardGutter, cell.y + kBrowserCardGutter, + cell.right() - kBrowserCardGutter, cell.bottom() - kBrowserCardGutter); } Rect cardThumbnailRect(const BrowserLayout& layout, int index) { if (index < 0) return Rect{}; const Rect content = cardContentRect(layout, index); - const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height())); - return Rect{content.left, content.top, content.right, content.top + thumbH}; + const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height)); + return Rect::ltrb(content.x, content.y, content.right(), content.y + thumbH); } Rect cardLabelRect(const BrowserLayout& layout, int index) { if (index < 0) return Rect{}; const Rect content = cardContentRect(layout, index); const Rect thumb = cardThumbnailRect(layout, index); - return Rect{content.left, thumb.bottom, content.right, content.bottom}; + return Rect::ltrb(content.x, thumb.bottom(), content.right(), content.bottom()); } int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { if (cardCount <= 0) return -1; if (!contains(layout.grid, x, y)) return -1; const int cols = std::max(1, layout.columns); - const int col = (x - layout.grid.left) / kBrowserCardWidth; - const int row = (y - layout.grid.top) / kBrowserCardHeight; + const int col = (x - layout.grid.x) / kBrowserCardWidth; + const int row = (y - layout.grid.y) / kBrowserCardHeight; if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone) const int index = row * cols + col; if (index < 0 || index >= cardCount) return -1; @@ -79,9 +79,9 @@ int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) { if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{}; const Rect& strip = layout.tabStrip; - const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount); - const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount); - return Rect{left, strip.top, right, strip.bottom}; + const int left = tabEdge(strip.x, std::max(0, strip.width), index, tabCount); + const int right = tabEdge(strip.x, std::max(0, strip.width), index + 1, tabCount); + return Rect::ltrb(left, strip.y, right, strip.bottom()); } int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { @@ -93,4 +93,4 @@ int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { return -1; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/capture_browser.h b/src/core/instrument/ui/capture_browser.h similarity index 96% rename from src/vst/capture_browser.h rename to src/core/instrument/ui/capture_browser.h index 864f378..a6ea38e 100644 --- a/src/vst/capture_browser.h +++ b/src/core/instrument/ui/capture_browser.h @@ -20,9 +20,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a // peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. @@ -37,12 +37,12 @@ inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside struct BrowserLayout { Rect tabStrip; // top: the bank-filter tabs Rect grid; // below the tabs: where the capture cards tile - int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width() + int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width }; // Divide a (w x h) browser area into its regions and compute the column count. Pure: same // inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never -// exceeds the area); the grid takes the rest. columns = max(1, grid.width()/cardWidth) so a +// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a // browser narrower than one card still lays out a single column. A zero/negative size // yields empty rects + columns==1. BrowserLayout layoutBrowser(int w, int h); @@ -89,4 +89,4 @@ Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); // tab strip. Pure. int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/core/instrument/ui/curve_popup.cpp b/src/core/instrument/ui/curve_popup.cpp new file mode 100644 index 0000000..c01b1b0 --- /dev/null +++ b/src/core/instrument/ui/curve_popup.cpp @@ -0,0 +1,41 @@ +// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes. + +#include "core/instrument/ui/curve_popup.h" + +#include + +namespace reasampler::instrument::ui { + +namespace { +int clampDim(int want, int lo, int hi, int windowDim) { + const int clamped = (std::max)(lo, (std::min)(hi, want)); + return (std::min)(clamped, (std::max)(0, windowDim)); +} +} // namespace + +CurvePopupLayout computeCurvePopup(int w, int h) { + CurvePopupLayout out; + const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w); + const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h); + const int left = (w - sheetW) / 2; + const int top = (h - sheetH) / 2; + out.sheet = Rect::ltrb(left, top, left + sheetW, top + sheetH); + + const int titleBottom = out.sheet.y + kCurvePopupTitleH; + const int closeTop = out.sheet.y + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2; + out.close = Rect::ltrb(out.sheet.right() - kCurvePopupPad - kCurvePopupCloseSize, closeTop, + out.sheet.right() - kCurvePopupPad, closeTop + kCurvePopupCloseSize); + out.title = Rect::ltrb(out.sheet.x + kCurvePopupPad, out.sheet.y, + out.close.x - kCurvePopupPad, titleBottom); + + out.curveBox = Rect::ltrb(out.sheet.x + kCurvePopupPad, titleBottom + 2, + out.sheet.right() - kCurvePopupPad, + out.sheet.bottom() - kCurvePopupPad); + return out; +} + +bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) { + return !contains(layout.sheet, x, y); +} + +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/curve_popup.h b/src/core/instrument/ui/curve_popup.h similarity index 94% rename from src/vst/curve_popup.h rename to src/core/instrument/ui/curve_popup.h index 3909b85..4abbf63 100644 --- a/src/vst/curve_popup.h +++ b/src/core/instrument/ui/curve_popup.h @@ -15,9 +15,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains +#include "core/instrument/ui/editor_geometry.h" // Rect, contains -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed popup metrics (spec r11), exposed so the shell and tests agree. inline constexpr int kCurvePopupMinW = 360; @@ -45,4 +45,4 @@ CurvePopupLayout computeCurvePopup(int w, int h); // The shell additionally gates on "no drag in flight" (spec). Pure. bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp similarity index 64% rename from src/vst/editor_geometry.cpp rename to src/core/instrument/ui/editor_geometry.cpp index f838420..cce57f7 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/core/instrument/ui/editor_geometry.cpp @@ -1,10 +1,10 @@ // editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. -#include "editor_geometry.h" +#include "core/instrument/ui/editor_geometry.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -17,10 +17,8 @@ constexpr int kButtonHeight = 24; } // namespace -bool contains(const Rect& r, int x, int y) { - if (r.width() <= 0 || r.height() <= 0) return false; - return x >= r.left && x < r.right && y >= r.top && y < r.bottom; -} +// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open +// semantics, re-exported through the header's using-declaration. EditorLayout layoutEditor(int w, int h) { // Clamp the surface to non-negative extents so a degenerate view can't produce @@ -32,18 +30,18 @@ EditorLayout layoutEditor(int w, int h) { // Title bar spans the top, clamped so it never exceeds the client height. const int titleH = std::min(kTitleBarHeight, ch); - out.titleBar = Rect{0, 0, cw, titleH}; + out.titleBar = Rect::ltrb(0, 0, cw, titleH); // Canvas is everything below the title bar. - out.canvas = Rect{0, titleH, cw, ch}; + out.canvas = Rect::ltrb(0, titleH, cw, ch); // Button sits at the top-left of the canvas, inset by a margin, and is clamped to // fit inside the canvas so it never overhangs on a small view. - const int bx = out.canvas.left + kButtonMargin; - const int by = out.canvas.top + kButtonMargin; - const int bRight = std::min(bx + kButtonWidth, out.canvas.right); - const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom); - out.button = Rect{bx, by, std::max(bx, bRight), std::max(by, bBottom)}; + const int bx = out.canvas.x + kButtonMargin; + const int by = out.canvas.y + kButtonMargin; + const int bRight = std::min(bx + kButtonWidth, out.canvas.right()); + const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom()); + out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom)); return out; } @@ -55,23 +53,23 @@ HitTarget hitTest(const EditorLayout& layout, int x, int y) { Rect sampleRowRect(const EditorLayout& layout, int index) { if (index < 0) return Rect{}; - const int top = layout.canvas.top + index * kSampleRowHeight; - return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight}; + const int top = layout.canvas.y + index * kSampleRowHeight; + return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight); } int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { if (rowCount <= 0) return -1; // Must be within the canvas horizontally and at/below its top. - if (x < layout.canvas.left || x >= layout.canvas.right) return -1; - if (y < layout.canvas.top) return -1; + if (x < layout.canvas.x || x >= layout.canvas.right()) return -1; + if (y < layout.canvas.y) return -1; // Clip at the canvas bottom: clicks in the canvas's dead-zone below the last - // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom. - if (y >= layout.canvas.bottom) return -1; - const int index = (y - layout.canvas.top) / kSampleRowHeight; + // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom(). + if (y >= layout.canvas.bottom()) return -1; + const int index = (y - layout.canvas.y) / kSampleRowHeight; if (index < 0 || index >= rowCount) return -1; // Guard the bottom edge: a click below the last row's bottom is outside. const Rect r = sampleRowRect(layout, index); - if (y >= r.bottom) return -1; + if (y >= r.bottom()) return -1; return index; } @@ -85,60 +83,60 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) { // Split the canvas vertically: the left column is the bank-sample list, the right // column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so // the split point never crosses the canvas edges. - const int canvasW = std::max(0, canvas.width()); + const int canvasW = std::max(0, canvas.width); const int splitW = canvasW / kZonePanelFraction; // width of the zone panel - const int splitX = std::max(canvas.left, canvas.right - splitW); + const int splitX = std::max(canvas.x, canvas.right() - splitW); - out.sampleList = Rect{canvas.left, canvas.top, splitX, canvas.bottom}; - out.zonePanel = Rect{splitX, canvas.top, canvas.right, canvas.bottom}; + out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom()); + out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom()); // "Add Zone" button spans the top of the zone panel, clamped to its height. - const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height())); + const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height)); out.addZoneButton = - Rect{out.zonePanel.left, out.zonePanel.top, out.zonePanel.right, - out.zonePanel.top + addH}; + Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(), + out.zonePanel.y + addH); // Zone rows stack below the button. - out.zoneRowArea = Rect{out.zonePanel.left, out.addZoneButton.bottom, - out.zonePanel.right, out.zonePanel.bottom}; + out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(), + out.zonePanel.right(), out.zonePanel.bottom()); return out; } Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) { if (index < 0) return Rect{}; - const int top = layout.sampleList.top + index * kSampleRowHeight; - return Rect{layout.sampleList.left, top, layout.sampleList.right, - top + kSampleRowHeight}; + const int top = layout.sampleList.y + index * kSampleRowHeight; + return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(), + top + kSampleRowHeight); } int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) { if (rowCount <= 0) return -1; const Rect& list = layout.sampleList; - if (x < list.left || x >= list.right) return -1; - if (y < list.top || y >= list.bottom) return -1; - const int index = (y - list.top) / kSampleRowHeight; + if (x < list.x || x >= list.right()) return -1; + if (y < list.y || y >= list.bottom()) return -1; + const int index = (y - list.y) / kSampleRowHeight; if (index < 0 || index >= rowCount) return -1; const Rect r = keymapSampleRowRect(layout, index); - if (y >= r.bottom) return -1; + if (y >= r.bottom()) return -1; return index; } Rect zoneRowRect(const KeymapEditorLayout& layout, int index) { if (index < 0) return Rect{}; - const int top = layout.zoneRowArea.top + index * kZoneRowHeight; - return Rect{layout.zoneRowArea.left, top, layout.zoneRowArea.right, - top + kZoneRowHeight}; + const int top = layout.zoneRowArea.y + index * kZoneRowHeight; + return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(), + top + kZoneRowHeight); } ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) { if (zoneCount <= 0) return ZoneHit{}; const Rect& area = layout.zoneRowArea; - if (x < area.left || x >= area.right) return ZoneHit{}; - if (y < area.top || y >= area.bottom) return ZoneHit{}; - const int index = (y - area.top) / kZoneRowHeight; + if (x < area.x || x >= area.right()) return ZoneHit{}; + if (y < area.y || y >= area.bottom()) return ZoneHit{}; + const int index = (y - area.y) / kZoneRowHeight; if (index < 0 || index >= zoneCount) return ZoneHit{}; const Rect row = zoneRowRect(layout, index); - if (y >= row.bottom) return ZoneHit{}; + if (y >= row.bottom()) return ZoneHit{}; // Seven mini-buttons pinned to the right edge, right-to-left: // delete, root+, root-, high+, high-, low+, low- @@ -150,7 +148,7 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int ZoneField::kDelete, }; const int slots = 7; - const int ctrlBlockLeft = row.right - slots * kZoneCtrlWidth; + const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth; if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth; if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone}; @@ -161,4 +159,4 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { return contains(layout.addZoneButton, x, y); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/editor_geometry.h b/src/core/instrument/ui/editor_geometry.h similarity index 91% rename from src/vst/editor_geometry.h rename to src/core/instrument/ui/editor_geometry.h index 8ca0615..50fdaee 100644 --- a/src/vst/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -12,24 +12,17 @@ #pragma once -namespace reasampler::vst { +#include "core/ui/rect.h" -// A plain integer rectangle. left/top inclusive, right/bottom exclusive — the same -// half-open convention LICE/SWELL RECTs use, kept REAPER-free here. -struct Rect { - int left = 0; - int top = 0; - int right = 0; - int bottom = 0; +namespace reasampler::instrument::ui { - int width() const { return right - left; } - int height() const { return bottom - top; } -}; - -// Returns true if (x, y) falls inside r under the half-open convention -// (left <= x < right, top <= y < bottom). A zero-or-negative-area rect contains -// nothing. -bool contains(const Rect& r, int x, int y); +// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former +// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage, +// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same +// half-open convention). Aliased here so every instrument-ui call site keeps its +// established `Rect` / `contains` spelling. +using Rect = ::reasampler::ui::Rect; +using ::reasampler::ui::contains; // The regions the spike editor draws, derived from the current view size. All are // clamped to the client area so a degenerate (too-small) view never yields a region @@ -93,7 +86,7 @@ inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band heig // The keymap editor's regions, derived from the (w x h) client area. All clamp to the // canvas so a degenerate view yields in-bounds rects. struct KeymapEditorLayout { - EditorLayout base; // title bar + canvas (the sample list uses base.canvas.left half) + EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half) Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows Rect addZoneButton; // top of the zone panel @@ -146,4 +139,4 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int // True if (x, y) lands on the "Add Zone" button. Pure. bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/embed_strip.cpp b/src/core/instrument/ui/embed_strip.cpp similarity index 78% rename from src/vst/embed_strip.cpp rename to src/core/instrument/ui/embed_strip.cpp index e1bf0d9..3f1a02e 100644 --- a/src/vst/embed_strip.cpp +++ b/src/core/instrument/ui/embed_strip.cpp @@ -1,10 +1,10 @@ // embed_strip.cpp — see embed_strip.h. Pure math; no host types. -#include "embed_strip.h" +#include "core/instrument/ui/embed_strip.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -42,22 +42,22 @@ EmbedLayout layoutEmbed(int w, int h) { } const int keymapBottom = ch - bandH; - out.keymap = Rect{0, 0, cw, keymapBottom}; - out.levelBand = Rect{0, keymapBottom, cw, ch}; + out.keymap = Rect::ltrb(0, 0, cw, keymapBottom); + out.levelBand = Rect::ltrb(0, keymapBottom, cw, ch); return out; } Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) { const Rect& band = layout.keymap; - const int bandWidth = std::max(0, band.width()); + const int bandWidth = std::max(0, band.width); int lo = clampNote(lowNote); int hi = clampNote(highNote); if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts - const int leftX = keyEdgeToX(band.left, bandWidth, lo); - const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1); - return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom}; + const int leftX = keyEdgeToX(band.x, bandWidth, lo); + const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1); + return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom()); } int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, @@ -74,13 +74,13 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount Rect levelFillRect(const EmbedLayout& layout, double level) { const Rect& band = layout.levelBand; - if (band.width() <= 0 || band.height() <= 0) return Rect{}; + if (band.width <= 0 || band.height <= 0) return Rect{}; double l = level; if (l < 0.0) l = 0.0; if (l > 1.0) l = 1.0; - const int fillW = static_cast(l * band.width()); + const int fillW = static_cast(l * band.width); if (fillW <= 0) return Rect{}; - return Rect{band.left, band.top, band.left + fillW, band.bottom}; + return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom()); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/embed_strip.h b/src/core/instrument/ui/embed_strip.h similarity index 95% rename from src/vst/embed_strip.h rename to src/core/instrument/ui/embed_strip.h index b16a88d..df32b24 100644 --- a/src/vst/embed_strip.h +++ b/src/core/instrument/ui/embed_strip.h @@ -18,9 +18,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's // horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle. @@ -54,7 +54,7 @@ struct EmbedLayout { EmbedLayout layoutEmbed(int w, int h); // The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] -// (inclusive). The 128-key span maps linearly across keymap.width(); the returned rect +// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect // spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. // 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low // is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect, @@ -73,4 +73,4 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount // (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. Rect levelFillRect(const EmbedLayout& layout, double level); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp similarity index 88% rename from src/vst/envelope_edit.cpp rename to src/core/instrument/ui/envelope_edit.cpp index 56381fa..68ab989 100644 --- a/src/vst/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -1,24 +1,19 @@ // envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types. -#include "envelope_edit.h" +#include "core/instrument/ui/envelope_edit.h" #include #include // std::abs -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { -double clamp(double v, double lo, double hi) { - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} // Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the // area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX. double secondsPerPixel(const Rect& area, double totalSeconds) { - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (w <= 0 || totalSeconds <= 0.0) return 0.0; return totalSeconds / static_cast(w); } @@ -36,7 +31,7 @@ double gateSecondsPerPixel(const Rect& area) { // Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one // pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY. double levelPerPixel(const Rect& area) { - const int h = std::max(0, area.height()); + const int h = std::max(0, area.height); if (h <= 1) return 0.0; return 1.0 / static_cast(h - 1); } @@ -118,23 +113,23 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect // because every segment stays >= 0), so the [0, max] clamp is the whole constraint. case EnvNode::AttackEnd: out.attackSeconds = - clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); + std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: - out.holdSeconds = clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); + out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); break; case EnvNode::DecayEnd: { // Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower // level, so subtract the level delta). - out.decaySeconds = clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); + out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); const double lvlPerPx = levelPerPixel(area); const double dLevel = -static_cast(dyPixels) * lvlPerPx; - out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); + out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); break; } case EnvNode::ReleaseEnd: out.releaseSeconds = - clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); + std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); break; // --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED @@ -154,7 +149,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0; const double hi = std::min(bounds.maxFadeInFraction, 1.0 - std::max(0.0, grabEnv.fadeOutFraction)); - out.fadeInFraction = clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi)); + out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi)); break; } case EnvNode::FadeOutStart: { @@ -165,13 +160,13 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0; const double hi = std::min(bounds.maxFadeOutFraction, 1.0 - std::max(0.0, grabEnv.fadeInFraction)); - out.fadeOutFraction = clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi)); + out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi)); break; } case EnvNode::LengthEnd: { // LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it. const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0; - out.lengthFraction = clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction); + out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction); break; } @@ -182,4 +177,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect return out; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h similarity index 96% rename from src/vst/envelope_edit.h rename to src/core/instrument/ui/envelope_edit.h index abd7c62..808667d 100644 --- a/src/vst/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -42,10 +42,10 @@ #include #include -#include "editor_geometry.h" // Rect -#include "envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY +#include "core/instrument/ui/editor_geometry.h" // Rect +#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and // y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a @@ -105,4 +105,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect double totalSeconds, const EnvClampBounds& bounds, int dxPixels, int dyPixels); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp similarity index 90% rename from src/vst/envelope_overlay.cpp rename to src/core/instrument/ui/envelope_overlay.cpp index f413e41..2876f51 100644 --- a/src/vst/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -1,25 +1,29 @@ // envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types. -#include "envelope_overlay.h" +#include "core/instrument/ui/envelope_overlay.h" + +#include "core/util/clamp01.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { + +using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) int timeToX(const Rect& area, double totalSeconds, double t) { - const int w = std::max(0, area.width()); - if (w <= 0 || totalSeconds <= 0.0) return area.left; + const int w = std::max(0, area.width); + if (w <= 0 || totalSeconds <= 0.0) return area.x; if (t < 0.0) t = 0.0; // Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the - // last in-bounds column area.right-1. Clamp in DOUBLE space BEFORE the integer cast — a huge + // last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge // t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round. double px = (t / totalSeconds) * static_cast(w); if (px > static_cast(w - 1)) px = static_cast(w - 1); - return area.left + static_cast(px + 0.5); + return area.x + static_cast(px + 0.5); } int gateTimedWidth(const Rect& area) { - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (w <= 0) return 0; const int sustainPx = static_cast(kGateSustainDisplayFraction * static_cast(w) + 0.5); @@ -38,24 +42,19 @@ double gatePxPerSecond(const Rect& area) { } int levelToY(const Rect& area, double level) { - const int h = std::max(0, area.height()); - if (h <= 0) return area.top; + const int h = std::max(0, area.height); + if (h <= 0) return area.y; if (level < 0.0) level = 0.0; if (level > 1.0) level = 1.0; // Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The // range spans (h-1) pixels so both endpoints land ON a drawable row. const int span = h - 1; const long dy = static_cast((1.0 - level) * static_cast(span) + 0.5); - return area.top + static_cast(dy); + return area.y + static_cast(dy); } namespace { -double clamp01(double v) { - if (v < 0.0) return 0.0; - if (v > 1.0) return 1.0; - return v; -} EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) { EnvVertex v; @@ -71,12 +70,12 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou // DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a // huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge). EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { - const int w = std::max(1, area.width()); + const int w = std::max(1, area.width); if (px < 0.0) px = 0.0; if (px > static_cast(w - 1)) px = static_cast(w - 1); EnvVertex v; v.node = node; - v.x = area.left + static_cast(px + 0.5); + v.x = area.x + static_cast(px + 0.5); v.y = levelToY(area, level); v.level = level; return v; @@ -95,7 +94,7 @@ std::vector gatePolyline(const AmpEnvelope& env, const Rect& area) { // gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually // grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain // plateau is the fixed reserve between DecayEnd and ReleaseStart. - const int W = std::max(1, area.width()); + const int W = std::max(1, area.width); const double sustainPx = static_cast(W - gateTimedWidth(area)); const double sep = static_cast(kGateNodeSepPx); const double pps = gatePxPerSecond(area); @@ -162,7 +161,7 @@ std::vector triggerPolyline(const AmpEnvelope& env, const Rect& area, std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds) { - if (area.width() <= 0 || area.height() <= 0 || totalSeconds <= 0.0) { + if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) { // Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line. return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0), vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)}; @@ -173,4 +172,4 @@ std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& : triggerPolyline(env, area, totalSeconds); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h similarity index 92% rename from src/vst/envelope_overlay.h rename to src/core/instrument/ui/envelope_overlay.h index fc3780e..ee839cf 100644 --- a/src/vst/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -25,7 +25,7 @@ // the vertical axis is LEVEL (0 at rect bottom, 1 at rect top). // // BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas: -// x in [area.left, area.right-1], y in [area.top, area.bottom-1] (half-open rect convention). +// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention). // No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no // longer needed (and never fires) in the shell. // @@ -33,8 +33,8 @@ // * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart // remain the only non-draggable anchors). // * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when -// v.x >= waveArea.right" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd -// (Trigger, at full length / zero fade-out) now land at area.right-1 and MUST get handles. +// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd +// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles. // * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param // domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every // segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the @@ -60,9 +60,9 @@ #include #include -#include "editor_geometry.h" // Rect — the shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the // geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases. @@ -173,7 +173,7 @@ inline constexpr int kGateNodeSepPx = 8; // (param clamps are caller-supplied in envelope_edit); only layout does. inline constexpr double kGateStageMaxSeconds = 2.0; -// The pixel width of the Gate timed region: area.width() minus the sustain-plateau reserve, +// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve, // floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0 // for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale. int gateTimedWidth(const Rect& area); @@ -188,7 +188,7 @@ double gatePxPerSecond(const Rect& area); // Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds` // wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive); -// y maps level 0..1 across [area.bottom-1 .. area.top] (level 1 at the TOP). The polyline reads +// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads // left-to-right in draw order, Origin first. // // TIME BASE (FA2). @@ -205,25 +205,25 @@ double gatePxPerSecond(const Rect& area); // lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past // the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge). // -// BOUNDS: every vertex is inside the canvas — x in [area.left, area.right-1], y in -// [area.top, area.bottom-1]. Nothing maps past area.right (the pre-FA2 release tail is gone). A +// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in +// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A // degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline // [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same // polyline. std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds); -// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.left, t=totalSeconds -> -// area.right-1, linear, CLAMPED on both sides (t < 0 pins to area.left; t past totalSeconds pins -// to area.right-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields -// area.left. Pure — the shared time->x map the Trigger polyline and the node hit-test +// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds -> +// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins +// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields +// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test // (envelope_edit) use, so the drawn handle and its grab region agree. int timeToX(const Rect& area, double totalSeconds, double t); -// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.top, level 0 -> area.bottom-1 +// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1 // (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is -// clamped to [0,1]. A zero-height area yields area.top. Pure — the shared level->y map the polyline +// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline // and the node hit-test share. int levelToY(const Rect& area, double level); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/keyboard_strip.cpp b/src/core/instrument/ui/keyboard_strip.cpp similarity index 86% rename from src/vst/keyboard_strip.cpp rename to src/core/instrument/ui/keyboard_strip.cpp index 98638b1..f6795bb 100644 --- a/src/vst/keyboard_strip.cpp +++ b/src/core/instrument/ui/keyboard_strip.cpp @@ -1,10 +1,10 @@ // keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types. -#include "keyboard_strip.h" +#include "core/instrument/ui/keyboard_strip.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -30,24 +30,24 @@ StripLayout layoutStrip(int w, int h) { const int cw = std::max(0, w); const int ch = std::max(0, h); StripLayout out; - out.keys = Rect{0, 0, cw, ch}; + out.keys = Rect::ltrb(0, 0, cw, ch); return out; } int keyLeftX(const StripLayout& layout, int note) { const Rect& band = layout.keys; - const int bandWidth = std::max(0, band.width()); + const int bandWidth = std::max(0, band.width); // note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to // get a key's right edge, and 128 maps to the band right. const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); - return keyEdgeToX(band.left, bandWidth, edge); + return keyEdgeToX(band.x, bandWidth, edge); } Rect keyRect(const StripLayout& layout, int note) { const int n = clampNote(note); const int leftX = keyLeftX(layout, n); const int rightX = keyLeftX(layout, n + 1); - return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; + return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); } Rect rootMarkerRect(const StripLayout& layout, int rootNote) { @@ -57,11 +57,11 @@ Rect rootMarkerRect(const StripLayout& layout, int rootNote) { int keyAtPoint(const StripLayout& layout, int x, int y) { const Rect& band = layout.keys; if (!contains(band, x, y)) return -1; - const int bandWidth = std::max(0, band.width()); + const int bandWidth = std::max(0, band.width); if (bandWidth <= 0) return -1; // Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide - // the pixel offset back to a key; clamp defensively (a point on band.right-1 maps to 127). - const int offset = x - band.left; + // the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127). + const int offset = x - band.x; int note = (offset * kStripKeyCount) / bandWidth; return clampNote(note); } @@ -72,22 +72,22 @@ Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) { if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts const int leftX = keyLeftX(layout, lo); const int rightX = keyLeftX(layout, hi + 1); - return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; + return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); } ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) { const Rect bar = zoneBarRect(layout, lowNote, highNote); if (!contains(bar, x, y)) return ZoneGrab::kNone; - const int barW = bar.width(); + const int barW = bar.width; // A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so // a click exactly on the midpoint resizes low (deterministic). if (barW < 2 * kStripEdgeGrabWidth) { - const int mid = bar.left + barW / 2; + const int mid = bar.x + barW / 2; return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; } - if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; - if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; + if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; + if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; return ZoneGrab::kBody; } @@ -127,7 +127,7 @@ bool isNaturalKey(int note) { int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { if (dxPixels == 0) return clampNote(startNote); - const int bandWidth = std::max(0, layout.keys.width()); + const int bandWidth = std::max(0, layout.keys.width); if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion // Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag // agree across the full strip, even on non-divisible-by-128 widths. The proportional @@ -146,4 +146,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { return clampNote(startNote + shift); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/keyboard_strip.h b/src/core/instrument/ui/keyboard_strip.h similarity index 96% rename from src/vst/keyboard_strip.h rename to src/core/instrument/ui/keyboard_strip.h index ac8b4c3..2a6902b 100644 --- a/src/vst/keyboard_strip.h +++ b/src/core/instrument/ui/keyboard_strip.h @@ -23,9 +23,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The full MIDI key span the strip maps across its width: 128 keys (0..127). Named // distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay @@ -42,7 +42,7 @@ inline constexpr int kStripEdgeGrabWidth = 6; // takes the whole area today (a future octave-label lane can carve a sub-band here without // changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect. struct StripLayout { - Rect keys; // the key band: the 128-key span maps linearly across keys.width() + Rect keys; // the key band: the 128-key span maps linearly across keys.width }; // Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or @@ -50,7 +50,7 @@ struct StripLayout { StripLayout layoutStrip(int w, int h); // The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key -// span maps linearly across keys.width(); key N occupies the half-open pixel range +// span maps linearly across keys.width; key N occupies the half-open pixel range // [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's // right edge (so a key's right edge is keyLeftX(note+1)). Pure. int keyLeftX(const StripLayout& layout, int note); @@ -128,4 +128,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); // pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types. bool isNaturalKey(int note); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp similarity index 79% rename from src/vst/knob_deck.cpp rename to src/core/instrument/ui/knob_deck.cpp index 9df5b8c..06be9b4 100644 --- a/src/vst/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -1,10 +1,10 @@ // knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes. -#include "knob_deck.h" +#include "core/instrument/ui/knob_deck.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -33,19 +33,20 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { out.id = g.id; out.box = box; - const int captionTop = box.top + kDeckGroupPadY; - const int innerLeft = box.left + kDeckGroupPadX; - const int innerRight = box.right - kDeckGroupPadX; + const int captionTop = box.y + kDeckGroupPadY; + const int innerLeft = box.x + kDeckGroupPadX; + const int innerRight = box.right() - kDeckGroupPadX; // Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home). - out.caption = Rect{innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH}; + out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH); if (g.captionToggle.id >= 0) { const int segW = g.captionToggle.segWidth; const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2; - const Rect seg1{innerRight - segW, togTop, innerRight, togTop + kDeckToggleH}; - const Rect seg0{seg1.left - segW, togTop, seg1.left, togTop + kDeckToggleH}; + const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH); + const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH); out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1}; - out.caption.right = seg0.left - kDeckToggleGap; // caption text stops at the toggle + // Caption text stops at the toggle: pull the right edge in (XYWH: shrink width). + out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x; } // Knob row: fixed cells left-to-right, then the optional row toggle. @@ -54,12 +55,12 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { for (int id : g.cellIds) { DeckCellLayout c; c.id = id; - c.cell = Rect{x, cellTop, x + kDeckCellW, cellTop + kDeckCellH}; + c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH); const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2; const int knobTop = cellTop + 4; - c.knob = Rect{knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize}; + c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); const int labelTop = knobTop + kDeckKnobSize + 4; - c.label = Rect{c.cell.left, labelTop, c.cell.right, labelTop + kDeckCellLabelH}; + c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH); out.cells.push_back(c); x += kDeckCellW; } @@ -67,8 +68,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { if (!g.cellIds.empty()) x += kDeckToggleGap; const int segW = g.rowToggle.segWidth; const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2; - const Rect seg0{x, togTop, x + segW, togTop + kDeckToggleH}; - const Rect seg1{seg0.right, togTop, seg0.right + segW, togTop + kDeckToggleH}; + const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH); + const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH); out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1}; } return out; @@ -120,9 +121,9 @@ DeckLayout layoutDeck(const std::vector& groups, int left, int to rowHasGroup = false; } if (rowHasGroup) x += kDeckGroupGap; - const Rect box{x, y, x + w, y + kDeckGroupH}; + const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH); out.groups.push_back(layoutGroup(g, box)); - x = box.right; + x = box.right(); rowHasGroup = true; } out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap; @@ -152,4 +153,4 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { return {}; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/knob_deck.h b/src/core/instrument/ui/knob_deck.h similarity index 97% rename from src/vst/knob_deck.h rename to src/core/instrument/ui/knob_deck.h index 5a1a890..5ffa3fc 100644 --- a/src/vst/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -27,9 +27,9 @@ #include -#include "editor_geometry.h" // Rect, contains — the shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed deck metrics (spec r11), exposed so the shell and tests agree. inline constexpr int kDeckCellW = 48; // one knob cell @@ -130,4 +130,4 @@ struct DeckHit { // Pure — the shell's routing entry point. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/param_slider.cpp b/src/core/instrument/ui/param_slider.cpp similarity index 71% rename from src/vst/param_slider.cpp rename to src/core/instrument/ui/param_slider.cpp index a080e43..bc2b663 100644 --- a/src/vst/param_slider.cpp +++ b/src/core/instrument/ui/param_slider.cpp @@ -1,30 +1,34 @@ // param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 // editor parameter panel. No host types; only the shared Rect + contains(). -#include "param_slider.h" +#include "core/instrument/ui/param_slider.h" + +#include "core/util/clamp01.h" #include #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { + +using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) std::vector layoutControls(const Rect& panel, const std::vector& controls) { std::vector out; - if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out; + if (controls.empty() || panel.width <= 0 || panel.height <= 0) return out; out.reserve(controls.size()); // The label column is clamped so a narrow panel still leaves a control column. - const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2)); - int rowTop = panel.top; + const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width / 2)); + int rowTop = panel.y; for (const ControlDesc& d : controls) { ControlRow r; r.id = d.id; r.kind = d.kind; const int rowBottom = rowTop + kControlRowHeight; - r.row = Rect{panel.left, rowTop, panel.right, rowBottom}; - r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom}; - r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom}; + r.row = Rect::ltrb(panel.x, rowTop, panel.right(), rowBottom); + r.label = Rect::ltrb(panel.x, rowTop, panel.x + labelW, rowBottom); + r.control = Rect::ltrb(panel.x + labelW, rowTop, panel.right(), rowBottom); out.push_back(r); rowTop = rowBottom + kControlRowGap; } @@ -33,13 +37,13 @@ std::vector layoutControls(const Rect& panel, Rect toggleSegmentRect(const Rect& control, int seg) { if (seg < 0 || seg >= kToggleSegments) return Rect{}; - const int w = control.width(); - if (w <= 0 || control.height() <= 0) return Rect{}; + const int w = control.width; + if (w <= 0 || control.height <= 0) return Rect{}; const int segW = w / kToggleSegments; - const int left = control.left + seg * segW; + const int left = control.x + seg * segW; // The last segment absorbs the width remainder so the segments tile the whole control. - const int right = (seg == kToggleSegments - 1) ? control.right : left + segW; - return Rect{left, control.top, right, control.bottom}; + const int right = (seg == kToggleSegments - 1) ? control.right() : left + segW; + return Rect::ltrb(left, control.y, right, control.bottom()); } int toggleSegmentHitTest(const Rect& control, int x, int y) { @@ -52,31 +56,31 @@ int toggleSegmentHitTest(const Rect& control, int x, int y) { Rect sliderTrackRect(const Rect& control) { // Inset a half-handle at each end so the handle stays fully inside the control at value - // 0 and 1. The handle CENTER ranges across [track.left, track.right]. + // 0 and 1. The handle CENTER ranges across [track.x, track.right()]. const int half = kSliderHandleWidth / 2; - if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{}; - return Rect{control.left + half, control.top, control.right - half, control.bottom}; + if (control.width <= kSliderHandleWidth || control.height <= 0) return Rect{}; + return Rect::ltrb(control.x + half, control.y, control.right() - half, control.bottom()); } Rect sliderHandleRect(const Rect& control, double value) { const Rect track = sliderTrackRect(control); - if (track.width() <= 0) return Rect{}; + if (track.width <= 0) return Rect{}; if (value < 0.0) value = 0.0; if (value > 1.0) value = 1.0; - const int span = track.width(); // handle-center movable span - const int centerX = track.left + static_cast(value * span + 0.5); + const int span = track.width; // handle-center movable span + const int centerX = track.x + static_cast(value * span + 0.5); const int half = kSliderHandleWidth / 2; - return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth, - control.bottom}; + return Rect::ltrb(centerX - half, control.y, centerX - half + kSliderHandleWidth, + control.bottom()); } double valueAtPoint(const Rect& control, int x) { const Rect track = sliderTrackRect(control); - const int span = track.width(); + const int span = track.width; if (span <= 0) return 0.0; - if (x <= track.left) return 0.0; - if (x >= track.right) return 1.0; - return static_cast(x - track.left) / static_cast(span); + if (x <= track.x) return 0.0; + if (x >= track.right()) return 1.0; + return static_cast(x - track.x) / static_cast(span); } // --- Radial knob (Wave A FA4) --------------------------------------------------------- @@ -95,16 +99,15 @@ double normDeg(double deg) { return deg; } -double clamp01(double v) { return (std::min)(1.0, (std::max)(0.0, v)); } } // namespace KnobGeometry computeKnob(const Rect& cell) { - if (cell.width() <= 0 || cell.height() <= 0) return KnobGeometry{}; + if (cell.width <= 0 || cell.height <= 0) return KnobGeometry{}; KnobGeometry g; - g.centerX = (cell.left + cell.right) / 2.0; - g.centerY = (cell.top + cell.bottom) / 2.0; - g.radius = (std::min)(cell.width(), cell.height()) / 2.0; + g.centerX = (cell.x + cell.right()) / 2.0; + g.centerY = (cell.y + cell.bottom()) / 2.0; + g.radius = (std::min)(cell.width, cell.height) / 2.0; return g; } @@ -154,4 +157,4 @@ int controlAtPoint(const std::vector& rows, int x, int y) { return -1; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/param_slider.h b/src/core/instrument/ui/param_slider.h similarity index 97% rename from src/vst/param_slider.h rename to src/core/instrument/ui/param_slider.h index b853eaa..1cddbf7 100644 --- a/src/vst/param_slider.h +++ b/src/core/instrument/ui/param_slider.h @@ -24,9 +24,9 @@ #include -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed control-panel metrics, exposed so the shell and tests agree. inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap) @@ -81,7 +81,7 @@ int toggleSegmentHitTest(const Rect& control, int x, int y); // The slider track sub-rect inside a slider control's `control` rect: the control inset so the // handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle -// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value +// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value // ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an // empty rect. Pure. Rect sliderTrackRect(const Rect& control); @@ -177,4 +177,4 @@ double knobDragValue(double startValue, int dyPixels, // toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits. int controlAtPoint(const std::vector& rows, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp similarity index 88% rename from src/vst/waveform_view.cpp rename to src/core/instrument/ui/waveform_view.cpp index 2b6d70c..0d566d6 100644 --- a/src/vst/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -1,11 +1,11 @@ // waveform_view.cpp — see waveform_view.h. Pure math; no host types. -#include "waveform_view.h" +#include "core/instrument/ui/waveform_view.h" #include #include // std::abs (int overload) -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -18,21 +18,21 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) { } // namespace int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) { - const int w = std::max(0, area.width()); - if (frameCount <= 0 || w <= 0) return area.left; + const int w = std::max(0, area.width); + if (frameCount <= 0 || w <= 0) return area.x; const std::int64_t f = clampFrame(frame, frameCount); // Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line // visually centered on its frame; the divide is exact rational (multiply first). const std::int64_t num = f * static_cast(w) + frameCount / 2; - return area.left + static_cast(num / frameCount); + return area.x + static_cast(num / frameCount); } std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) { - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (frameCount <= 0 || w <= 0) return 0; - if (x <= area.left) return 0; - if (x >= area.right) return frameCount; - const std::int64_t dx = static_cast(x - area.left); + if (x <= area.x) return 0; + if (x >= area.right()) return frameCount; + const std::int64_t dx = static_cast(x - area.x); // Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw // agree at bin granularity. const std::int64_t num = dx * frameCount + static_cast(w) / 2; @@ -54,7 +54,7 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in int dxPixels) { const std::int64_t start = clampFrame(startFrame, frameCount); if (dxPixels == 0) return start; - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (frameCount <= 0 || w <= 0) return start; // no room to move // Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame). const std::int64_t magnitude = @@ -96,4 +96,4 @@ std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, return t; // no sign change in the whole buffer -> keep the raw (clamped) target } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/vst/waveform_view.h b/src/core/instrument/ui/waveform_view.h similarity index 89% rename from src/vst/waveform_view.h rename to src/core/instrument/ui/waveform_view.h index 4831cbf..34e6948 100644 --- a/src/vst/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -24,10 +24,12 @@ #include -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom -#include "peaks.h" // AudioSample (float), the mono PCM the snap scans +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans -namespace reasampler::vst { +namespace reasampler::instrument::ui { + +using audio::AudioSample; // The width (px) of a marker's grab region either side of its x line: a grab within this many // pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab @@ -35,14 +37,14 @@ namespace reasampler::vst { // distinguishable. inline constexpr int kMarkerGrabWidth = 5; -// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left, -// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an +// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x, +// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an // out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a -// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure. +// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure. int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); // The frame a point x (inside `area`) maps to under the inverse linear map, clamped to -// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount. +// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount. // frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips // to the same frame at bin granularity). std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); @@ -80,4 +82,4 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t target); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui \ No newline at end of file diff --git a/src/core/json/json.h b/src/core/json/json.h index ce1c281..2af28c3 100644 --- a/src/core/json/json.h +++ b/src/core/json/json.h @@ -138,7 +138,7 @@ public: // Captures the raw source text of one value verbatim (string-aware brace // matching), so a nested blob can be handed to its own parser — the - // bank_book -> BankIndex::deserialize seam. + // bank_book -> BankModel::deserialize seam. bool captureValue(std::string& raw); private: diff --git a/src/bank_book.cpp b/src/core/model/bank_book.cpp similarity index 84% rename from src/bank_book.cpp rename to src/core/model/bank_book.cpp index df5b3a9..20372ac 100644 --- a/src/bank_book.cpp +++ b/src/core/model/bank_book.cpp @@ -1,4 +1,4 @@ -#include "bank_book.h" +#include "core/model/bank_book.h" #include #include @@ -9,136 +9,16 @@ // // JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model // and view_mode_model. The book blob nests one bank object per bank, each carrying that -// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize), +// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), // so per-bank sample serialization stays owned by bank_model and is not duplicated // here. The book writer emits the bank envelope (id / displayName / ordinal) plus a -// raw "index" member whose value is the BankIndex blob verbatim; the parser splits +// raw "index" member whose value is the BankModel blob verbatim; the parser splits // the book envelope, then hands each nested index blob straight to -// BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped. +// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped. namespace reasampler { -// =========================================================================== -// SlotMap — the L7 gap-preserving display-position carrier (pure). See bank_book.h. -// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one -// slot per id. Every mutator restores it; queries assume it. -// =========================================================================== - -void SlotMap::sortBySlot() { - std::stable_sort(entries_.begin(), entries_.end(), - [](const Entry& a, const Entry& b) { return a.slot < b.slot; }); -} - -int SlotMap::slotOf(const std::string& id) const { - for (const auto& e : entries_) - if (e.id == id) return e.slot; - return -1; -} - -std::string SlotMap::idAt(int slot) const { - for (const auto& e : entries_) - if (e.slot == slot) return e.id; - return {}; -} - -int SlotMap::maxSlot() const { - int m = -1; - for (const auto& e : entries_) - if (e.slot > m) m = e.slot; - return m; -} - -std::vector SlotMap::orderedIds() const { - // entries_ is sorted ascending by slot, so a straight walk is display order. - std::vector out; - out.reserve(entries_.size()); - for (const auto& e : entries_) out.push_back(e.id); - return out; -} - -void SlotMap::append(const std::string& id) { - if (id.empty()) return; - remove(id); // an existing id is re-appended, not left in place - entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied - sortBySlot(); -} - -bool SlotMap::remove(const std::string& id) { - for (auto it = entries_.begin(); it != entries_.end(); ++it) { - if (it->id == id) { - entries_.erase(it); // leaves the slot empty — no re-pack - return true; - } - } - return false; -} - -bool SlotMap::reorder(const std::string& id, int targetSlot) { - if (slotOf(id) < 0) return false; // not mapped -> no mutation - if (targetSlot < 0) targetSlot = 0; - if (slotOf(id) == targetSlot) return false; // already there — true no-op - - // Detach the moving id first so the occupancy test below sees the post-move world. - remove(id); - - const bool occupied = !idAt(targetSlot).empty(); - if (occupied) { - // Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one, - // preserving relative order and interior gaps above the target. The moving id then - // takes targetSlot cleanly. - for (auto& e : entries_) - if (e.slot >= targetSlot) ++e.slot; - } - entries_.push_back(Entry{id, targetSlot}); - sortBySlot(); - return true; -} - -void SlotMap::resetDense(const std::vector& ids) { - entries_.clear(); - int slot = 0; - for (const auto& id : ids) { - if (id.empty()) continue; - if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id) - entries_.push_back(Entry{id, slot++}); - } - // Already ascending by construction; no sort needed. -} - -void SlotMap::reconcile(const std::vector& liveIds) { - // Drop markers whose sample left the index. - entries_.erase( - std::remove_if(entries_.begin(), entries_.end(), - [&](const Entry& e) { - return std::find(liveIds.begin(), liveIds.end(), e.id) == - liveIds.end(); - }), - entries_.end()); - // Append live ids that have no mapping yet (out-of-band index growth), in liveIds - // order, each to the next free slot after the current frontier. - for (const auto& id : liveIds) - if (slotOf(id) < 0) append(id); - sortBySlot(); -} - -bool SlotMap::operator==(const SlotMap& o) const { - return entries_ == o.entries_; -} - -SlotMap SlotMap::fromEntries(const std::vector>& pairs) { - SlotMap m; - for (const auto& [id, slot] : pairs) { - if (id.empty() || slot < 0) continue; // drop malformed pair - if (m.slotOf(id) >= 0) continue; // duplicate id: first wins - if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy - m.entries_.push_back(Entry{id, slot}); - } - m.sortBySlot(); - return m; -} - -// SlotMap::serialize is defined in the JSON writer section below (it reuses the -// shared core/json emit helpers). +// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05). // --------------------------------------------------------------------------- // BankBook — construction + bank lookup @@ -165,12 +45,12 @@ const Bank* BankBook::bank(const std::string& id) const { return nullptr; } -BankIndex* BankBook::index(const std::string& id) { +BankModel* BankBook::index(const std::string& id) { Bank* b = bank(id); return b ? &b->index : nullptr; } -const BankIndex* BankBook::index(const std::string& id) const { +const BankModel* BankBook::index(const std::string& id) const { const Bank* b = bank(id); return b ? &b->index : nullptr; } @@ -326,13 +206,13 @@ bool BankBook::evacuate(const std::string& id) { if (src == nullptr) return false; // Move every member into the pool, index-only, observing destination collapse. - // Snapshot the members first, then clear the source — BankIndex has no bulk move, + // Snapshot the members first, then clear the source — BankModel has no bulk move, // and adding into the pool must not alias the vector we are draining. - BankIndex& poolIndex = pool().index; + BankModel& poolIndex = pool().index; const std::vector members = src->index.all(); // copy for (const auto& s : members) poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash - src->index = BankIndex{}; // leave the evacuated bank empty + src->index = BankModel{}; // leave the evacuated bank empty return true; } @@ -346,12 +226,12 @@ bool BankBook::setActiveBank(const std::string& id) { return true; } -BankIndex& BankBook::activeIndex() { +BankModel& BankBook::activeIndex() { // activeBankId_ always names a live bank; it falls back to the pool on delete. return bank(activeBankId_)->index; } -const BankIndex& BankBook::activeIndex() const { +const BankModel& BankBook::activeIndex() const { return bank(activeBankId_)->index; } @@ -361,11 +241,11 @@ const BankIndex& BankBook::activeIndex() const { namespace { -// Adds `s` to `dest` and maps the BankIndex outcome onto the transfer outcome for +// Adds `s` to `dest` and maps the BankModel outcome onto the transfer outcome for // the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes // (absolute path / empty id) cannot occur here: the sample already passed add() on // the source side, so its path and id are already valid. -TransferResult applyDestAdd(BankIndex& dest, const Sample& s, TransferResult gained) { +TransferResult applyDestAdd(BankModel& dest, const Sample& s, TransferResult gained) { return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained; } @@ -445,7 +325,7 @@ bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& up namespace { // The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed. -std::vector indexIds(const BankIndex& idx) { +std::vector indexIds(const BankModel& idx) { std::vector ids; for (const auto& s : idx.all()) ids.push_back(s.id); return ids; @@ -557,20 +437,6 @@ using ObjWriter = json::Writer; } // namespace -std::string SlotMap::serialize() const { - // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). - std::string out; - out += '['; - for (std::size_t i = 0; i < entries_.size(); ++i) { - if (i) out += ','; - ObjWriter e(out); - e.keyStr("id", entries_[i].id); - e.keyRaw("slot", intToStr(entries_[i].slot)); - } - out += ']'; - return out; -} - std::string BankBook::serialize() const { std::string out; { @@ -578,7 +444,7 @@ std::string BankBook::serialize() const { root.keyRaw("version", intToStr(1)); root.keyStr("activeBank", activeBankId_); - // banks: array of { id, displayName, ordinal, index: }. + // banks: array of { id, displayName, ordinal, index: }. // The pool rides in as bank-zero, persisted identically to any named bank. root.keyBegin("banks"); out += '['; @@ -589,7 +455,7 @@ std::string BankBook::serialize() const { b.keyStr("displayName", banks_[i].displayName); b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); // The nested index is bank_model's own JSON, emitted verbatim so the - // per-sample shape stays owned by BankIndex::serialize (not duplicated). + // per-sample shape stays owned by BankModel::serialize (not duplicated). b.keyRaw("index", banks_[i].index.serialize()); // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the // parser defaults such a bank's slots from insertion order on load. @@ -638,7 +504,7 @@ bool parseBank(json::Reader& r, Bank& b) { } else if (key == "index") { std::string raw; if (!r.captureValue(raw)) return false; - auto idx = BankIndex::deserialize(raw); + auto idx = BankModel::deserialize(raw); if (!idx) return false; // a malformed nested index fails the whole parse b.index = std::move(*idx); haveIndex = true; @@ -718,7 +584,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks if (!r.parseString(activeBank)) return false; } else if (key == "samples") { // Legacy marker. The legacy index is re-parsed from the whole input below - // (BankIndex::deserialize owns that shape); here we only skip the value to + // (BankModel::deserialize owns that shape); here we only skip the value to // keep the scan well-formed and note that we saw it. sawSamples = true; if (!r.skipValue()) return false; @@ -734,7 +600,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- if (!sawBanks) { if (!sawSamples) return false; // neither shape's marker → malformed - auto legacy = BankIndex::deserialize(raw); + auto legacy = BankModel::deserialize(raw); if (!legacy) return false; Bank pool; pool.id = kPoolBankId; diff --git a/src/bank_book.h b/src/core/model/bank_book.h similarity index 78% rename from src/bank_book.h rename to src/core/model/bank_book.h index 9b4c6f8..6a66fe6 100644 --- a/src/bank_book.h +++ b/src/core/model/bank_book.h @@ -10,9 +10,9 @@ // -- What it is -------------------------------------------------------------- // // An ordered registry of banks. Each bank = { stable id, display name, ordinal, -// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are +// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are // UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is -// index-only (remove from source's BankIndex, add to destination's); files never +// index-only (remove from source's BankModel, add to destination's); files never // relocate — banks are logical groupings over one shared file pool. // // -- The pool (privileged, not special-cased) -------------------------------- @@ -42,113 +42,30 @@ #include #include -#include "bank_model.h" +#include "core/model/bank_model.h" +#include "core/model/slot_map.h" namespace reasampler { +// Q-W1 interim: this god module re-namespaces in its own split wave; until then the +// clean model types it wraps live in reasampler::model. +using namespace model; + // The pool's fixed identity. The id is reserved: createBank rejects it, and the // pool is always bank-zero. The name is fixed: renameBank rejects the pool. inline constexpr const char* kPoolBankId = "pool"; inline constexpr const char* kPoolBankName = "Pool"; -// SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: -// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a -// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps -// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty -// first row above an occupied second row). At most one id per slot (a slot is never -// double-occupied) and at most one slot per id (an id sits in exactly one place). -// -// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one -// sample into two banks may sit at different slots, so position is a per-bank display -// concern owned by the bank's membership. bank_model / Sample stay untouched. -// -// PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip. -class SlotMap { -public: - // The slot an id occupies, or -1 if the id is not mapped. O(N). - int slotOf(const std::string& id) const; - - // The id occupying `slot`, or "" if the slot is empty. O(N). - std::string idAt(int slot) const; - - // The highest occupied slot, or -1 when the map is empty. Defines the append - // frontier and (with trailing-empty trim) the content extent. - int maxSlot() const; - - // Ids in ASCENDING slot order (the deterministic display order). Empty slots - // produce no entry — the caller iterates occupants; sparse layout is a draw - // concern that reads slotOf/idAt, not this list. - std::vector orderedIds() const; - - // Places `id` at the next free slot after the last occupied one (append). If the - // id is already mapped it is first removed (leaving its old slot empty), then - // appended — an append never fills an earlier gap. No-op guard: empty id ignored. - void append(const std::string& id); - - // Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id - // keeps its position. Returns true if the id was mapped. - bool remove(const std::string& id); - - // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics): - // * target slot EMPTY -> `id` moves there; its old slot is left empty. - // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and - // every occupant at slot >= targetSlot (except `id` itself) shifts up by one, - // preserving their relative order and never colliding. Matches file-manager - // reorder. Interior gaps between shifted occupants are preserved as-is - // (shift is +1 on each occupant, so the gap structure above the target is kept). - // * negative targetSlot is clamped to 0. - // Returns false (no mutation) if `id` is not mapped. Deterministic. - bool reorder(const std::string& id, int targetSlot); - - // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), - // dropping any prior state. The migration path: a pre-L7 bank with no persisted - // slot data is seeded from its BankIndex insertion order, densely packed (no gaps), - // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped. - void resetDense(const std::vector& ids); - - // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left - // the index) and appends any live id that has NO mapping yet (a sample the index - // gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps - // the map consistent with the bank's membership without a re-pack. Deterministic: - // orphan appends follow `liveIds` order. - void reconcile(const std::vector& liveIds); - - bool empty() const { return entries_.empty(); } - std::size_t size() const { return entries_.size(); } - - bool operator==(const SlotMap& o) const; - - // JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the - // bank envelope's "slots" member by BankBook::serialize; parsed back by its parser. - // Round-trips losslessly with the rest of the bank. - std::string serialize() const; - - // Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces - // the map invariants defensively against a hand-edited blob: a duplicate id keeps - // its FIRST occurrence; a slot already taken by a kept id drops the later pair - // (never double-occupies); an empty id or negative slot is dropped. The result is - // sorted ascending by slot. reconcile() against live membership runs afterward, so - // a lossy repair here degrades gracefully rather than corrupting lookup. - static SlotMap fromEntries(const std::vector>& pairs); - -private: - struct Entry { - std::string id; - int slot = 0; - bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; } - }; - std::vector entries_; // kept sorted ascending by slot (invariant) - - void sortBySlot(); -}; +// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h. +// Included above because Bank carries one per bank. // One bank: a stable id, a display name, an ordinal (tab/display order), and its -// own BankIndex. The pool is the bank whose id == kPoolBankId. +// own BankModel. The pool is the bank whose id == kPoolBankId. struct Bank { std::string id; // stable, persisted; the pool's is kPoolBankId std::string displayName; // mutable for named banks; fixed "Pool" for the pool int ordinal = 0; // display order; pool is 0, named banks 1..N - BankIndex index; // this bank's samples + BankModel index; // this bank's samples SlotMap slots; // L7 display positions of this bank's samples (gap-preserving) bool isPool() const { return id == kPoolBankId; } @@ -251,10 +168,10 @@ public: // bank — an invalid set never corrupts state. bool setActiveBank(const std::string& id); - // The active bank's BankIndex — the index the capture layer adds to. Always + // The active bank's BankModel — the index the capture layer adds to. Always // valid (the active id always names a live bank; it falls back to the pool). - BankIndex& activeIndex(); - const BankIndex& activeIndex() const; + BankModel& activeIndex(); + const BankModel& activeIndex() const; // -- Sample movement (index-only; files never relocate) ------------------ @@ -334,7 +251,7 @@ public: // Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): // finds the bank holding `sampleId` and replaces its entry with `updated` - // (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in + // (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in // ordinal order and updates the FIRST holder (a sample id is unique within a // bank; the same id living in two banks via copy would update the earliest, which // is acceptable — re-capture operates on the panel's focused single selection). @@ -372,9 +289,9 @@ public: Bank* bank(const std::string& id); const Bank* bank(const std::string& id) const; - // The bank's BankIndex by id, or nullptr. Convenience over bank()->index. - BankIndex* index(const std::string& id); - const BankIndex* index(const std::string& id) const; + // The bank's BankModel by id, or nullptr. Convenience over bank()->index. + BankModel* index(const std::string& id); + const BankModel* index(const std::string& id) const; // The pool (always present). Never null. Bank& pool(); diff --git a/src/bank_model.cpp b/src/core/model/bank_model.cpp similarity index 96% rename from src/bank_model.cpp rename to src/core/model/bank_model.cpp index 07f92ce..27c8ae8 100644 --- a/src/bank_model.cpp +++ b/src/core/model/bank_model.cpp @@ -1,4 +1,4 @@ -#include "bank_model.h" +#include "core/model/bank_model.h" #include @@ -14,7 +14,7 @@ // shortest form that round-trips every IEEE-754 double exactly, so the // deserialize(serialize(x)) == x invariant holds bit-for-bit. -namespace reasampler { +namespace reasampler::model { // --------------------------------------------------------------------------- // equality @@ -74,10 +74,10 @@ static bool isAbsolutePath(const std::string& p) { } // --------------------------------------------------------------------------- -// BankIndex +// BankModel // --------------------------------------------------------------------------- -AddResult BankIndex::add(const Sample& sample) { +AddResult BankModel::add(const Sample& sample) { if (sample.id.empty()) return AddResult::RejectedEmptyId; if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath; @@ -88,7 +88,7 @@ AddResult BankIndex::add(const Sample& sample) { return AddResult::Added; } -bool BankIndex::remove(const std::string& id) { +bool BankModel::remove(const std::string& id) { for (auto it = samples_.begin(); it != samples_.end(); ++it) { if (it->id == id) { samples_.erase(it); @@ -98,7 +98,7 @@ bool BankIndex::remove(const std::string& id) { return false; } -bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) { +bool BankModel::updateInPlace(const std::string& id, const Sample& updated) { if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds for (auto& s : samples_) { if (s.id == id) { @@ -109,20 +109,20 @@ bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) { return false; } -const Sample* BankIndex::query(const std::string& id) const { +const Sample* BankModel::query(const std::string& id) const { for (const auto& s : samples_) if (s.id == id) return &s; return nullptr; } -const Sample* BankIndex::findByHash(const std::string& contentHash) const { +const Sample* BankModel::findByHash(const std::string& contentHash) const { if (contentHash.empty()) return nullptr; // empty hashes never dedup for (const auto& s : samples_) if (s.contentHash == contentHash) return &s; return nullptr; } -bool BankIndex::moveTier(const std::string& id, Tier tier) { +bool BankModel::moveTier(const std::string& id, Tier tier) { for (auto& s : samples_) { if (s.id == id) { s.tier = tier; @@ -132,7 +132,7 @@ bool BankIndex::moveTier(const std::string& id, Tier tier) { return false; } -std::vector BankIndex::byTier(Tier tier) const { +std::vector BankModel::byTier(Tier tier) const { std::vector out; for (const auto& s : samples_) if (s.tier == tier) out.push_back(s); @@ -223,7 +223,7 @@ void writeSample(std::string& out, const Sample& s) { } // namespace -std::string BankIndex::serialize() const { +std::string BankModel::serialize() const { std::string out; { ObjWriter root(out); @@ -410,7 +410,7 @@ bool parseSample(json::Reader& r, Sample& s) { return r.consume('}'); } -bool parseIndex(json::Reader& r, BankIndex& out) { +bool parseIndex(json::Reader& r, BankModel& out) { if (!r.consume('{')) return false; r.skipWs(); if (r.consume('}')) return true; // empty object — vacuously an empty index @@ -451,11 +451,11 @@ bool parseIndex(json::Reader& r, BankIndex& out) { } // namespace -std::optional BankIndex::deserialize(const std::string& blob) { - BankIndex idx; +std::optional BankModel::deserialize(const std::string& blob) { + BankModel idx; json::Reader r(blob); if (!parseIndex(r, idx)) return std::nullopt; return idx; } -} // namespace reasampler +} // namespace reasampler::model \ No newline at end of file diff --git a/src/bank_model.h b/src/core/model/bank_model.h similarity index 95% rename from src/bank_model.h rename to src/core/model/bank_model.h index 5ad8472..01cf8af 100644 --- a/src/bank_model.h +++ b/src/core/model/bank_model.h @@ -1,7 +1,7 @@ #pragma once // bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so // it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample -// bank: the `Sample` metadata struct and the `BankIndex` (add / remove / query / +// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query / // tier moves / dedup-by-hash + JSON round-trip to/from std::string). // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO @@ -12,7 +12,7 @@ #include #include -namespace reasampler { +namespace reasampler::model { // How the source audio was obtained. Kept in the pure core (no REAPER coupling); // the capture backends (M3/M8) map their own notion onto these. @@ -81,7 +81,7 @@ struct LoopPoints { // The metadata record for one captured sample. The audio itself lives in a // project-relative file; `relativePath` is ALWAYS relative (enforced at the -// BankIndex::add boundary — see AddResult). +// BankModel::add boundary — see AddResult). struct Sample { std::string id; // stable unique id (assigned by the caller) std::string displayName; @@ -128,7 +128,7 @@ struct Sample { Tier tier = Tier::Scratch; - std::string contentHash; // dedup key (see BankIndex) + std::string contentHash; // dedup key (see BankModel) std::optional provenance; // set only when resampled @@ -141,7 +141,7 @@ struct Sample { bool isAutoPrunable() const { return tier == Tier::Scratch; } }; -// Outcome of BankIndex::add. `add` rejects rather than silently mutating: +// Outcome of BankModel::add. `add` rejects rather than silently mutating: // - RejectedAbsolutePath: relativePath was absolute (precision invariant). // - RejectedEmptyId: id was empty (the collection is keyed by id). // - Collapsed: content hash matched an existing entry; the existing @@ -157,7 +157,7 @@ enum class AddResult { // An ordered, id-keyed collection of Samples with content-hash dedup, tier // moves/filtering, and lossless JSON round-trip. Insertion order is preserved // so a future panel (M5) can iterate in stable order. -class BankIndex { +class BankModel { public: // Adds a sample. Enforces the relative-paths-only invariant and dedups by // content hash (an equal-hash add collapses onto the existing entry rather @@ -200,7 +200,7 @@ public: std::size_t size() const { return samples_.size(); } bool empty() const { return samples_.empty(); } - bool operator==(const BankIndex& o) const { return samples_ == o.samples_; } + bool operator==(const BankModel& o) const { return samples_ == o.samples_; } // Serializes the whole index to a JSON string (lossless round-trip). std::string serialize() const; @@ -208,10 +208,10 @@ public: // Parses a JSON string produced by serialize(). Returns std::nullopt on // malformed / truncated input (error signaled, never UB). On success the // returned index satisfies deserialize(serialize(x)) == x. - static std::optional deserialize(const std::string& json); + static std::optional deserialize(const std::string& json); private: std::vector samples_; // insertion order preserved }; -} // namespace reasampler +} // namespace reasampler::model \ No newline at end of file diff --git a/src/owned_manifest.cpp b/src/core/model/owned_manifest.cpp similarity index 97% rename from src/owned_manifest.cpp rename to src/core/model/owned_manifest.cpp index ba21411..2dd68af 100644 --- a/src/owned_manifest.cpp +++ b/src/core/model/owned_manifest.cpp @@ -1,4 +1,4 @@ -#include "owned_manifest.h" +#include "core/model/owned_manifest.h" #include @@ -13,7 +13,7 @@ // // so a compact writer + a focused string-array domain parse is all it needs. -namespace reasampler { +namespace reasampler::model { // --------------------------------------------------------------------------- // path invariant (mirror of bank_model's isAbsolutePath) @@ -110,4 +110,4 @@ std::optional OwnedFileManifest::deserialize(const std::strin return m; } -} // namespace reasampler +} // namespace reasampler::model \ No newline at end of file diff --git a/src/owned_manifest.h b/src/core/model/owned_manifest.h similarity index 93% rename from src/owned_manifest.h rename to src/core/model/owned_manifest.h index fe3a3d7..629f67e 100644 --- a/src/owned_manifest.h +++ b/src/core/model/owned_manifest.h @@ -23,17 +23,17 @@ // -- The relative-paths-only invariant --------------------------------------- // // A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath -// and the persisted BankIndex). add() rejects an absolute path rather than guess a +// and the persisted BankModel). add() rejects an absolute path rather than guess a // relativization — the pure model has no project root, so a "normalization" would be -// a guess that could point at the wrong file (mirror of BankIndex::add's rejection). +// a guess that could point at the wrong file (mirror of BankModel::add's rejection). #include #include #include -namespace reasampler { +namespace reasampler::model { -// Outcome of an add(). Mirrors BankIndex::AddResult's honesty — the op reports what +// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what // happened rather than silently mutating on a bad request. // - Added: the path was new and recorded. // - AlreadyPresent: the path was already in the manifest (dedup no-op). @@ -88,4 +88,4 @@ private: std::vector paths_; // insertion order; deduplicated }; -} // namespace reasampler +} // namespace reasampler::model \ No newline at end of file diff --git a/src/provenance.cpp b/src/core/model/provenance.cpp similarity index 98% rename from src/provenance.cpp rename to src/core/model/provenance.cpp index 712394f..cd91e7b 100644 --- a/src/provenance.cpp +++ b/src/core/model/provenance.cpp @@ -1,4 +1,4 @@ -#include "provenance.h" +#include "core/model/provenance.h" #include @@ -25,7 +25,7 @@ // injection-proof on its own and can be embedded whole as one more length-prefixed // field of the fingerprint. -namespace reasampler { +namespace reasampler::model { bool CaptureRecipe::operator==(const CaptureRecipe& o) const { return scope == o.scope && sourceMode == o.sourceMode && @@ -159,4 +159,4 @@ std::optional detectParent( return parent; } -} // namespace reasampler +} // namespace reasampler::model \ No newline at end of file diff --git a/src/provenance.h b/src/core/model/provenance.h similarity index 99% rename from src/provenance.h rename to src/core/model/provenance.h index d8f5a66..1dd2ca0 100644 --- a/src/provenance.h +++ b/src/core/model/provenance.h @@ -35,7 +35,7 @@ #include #include -namespace reasampler { +namespace reasampler::model { // Capture scope, mirrored from render_settings' CaptureScope but kept independent // here so the pure provenance module does not pull the whole render_settings graph @@ -146,4 +146,4 @@ std::optional detectParent( const std::vector& sourceItemFiles, const std::vector& bankFiles); -} // namespace reasampler +} // namespace reasampler::model \ No newline at end of file diff --git a/src/core/model/slot_map.cpp b/src/core/model/slot_map.cpp new file mode 100644 index 0000000..2d60da7 --- /dev/null +++ b/src/core/model/slot_map.cpp @@ -0,0 +1,145 @@ +#include "core/model/slot_map.h" + +#include + +#include "core/json/json.h" + +// slot_map implementation (extracted from bank_book, Q-W1 T4-05). +// +// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one +// slot per id. Every mutator restores it; queries assume it. serialize rides the +// shared core/json emit helpers — the emitted fragment is byte-identical to the +// pre-extraction bank_book writer. + +namespace reasampler::model { + +void SlotMap::sortBySlot() { + std::stable_sort(entries_.begin(), entries_.end(), + [](const Entry& a, const Entry& b) { return a.slot < b.slot; }); +} + +int SlotMap::slotOf(const std::string& id) const { + for (const auto& e : entries_) + if (e.id == id) return e.slot; + return -1; +} + +std::string SlotMap::idAt(int slot) const { + for (const auto& e : entries_) + if (e.slot == slot) return e.id; + return {}; +} + +int SlotMap::maxSlot() const { + int m = -1; + for (const auto& e : entries_) + if (e.slot > m) m = e.slot; + return m; +} + +std::vector SlotMap::orderedIds() const { + // entries_ is sorted ascending by slot, so a straight walk is display order. + std::vector out; + out.reserve(entries_.size()); + for (const auto& e : entries_) out.push_back(e.id); + return out; +} + +void SlotMap::append(const std::string& id) { + if (id.empty()) return; + remove(id); // an existing id is re-appended, not left in place + entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied + sortBySlot(); +} + +bool SlotMap::remove(const std::string& id) { + for (auto it = entries_.begin(); it != entries_.end(); ++it) { + if (it->id == id) { + entries_.erase(it); // leaves the slot empty — no re-pack + return true; + } + } + return false; +} + +bool SlotMap::reorder(const std::string& id, int targetSlot) { + if (slotOf(id) < 0) return false; // not mapped -> no mutation + if (targetSlot < 0) targetSlot = 0; + if (slotOf(id) == targetSlot) return false; // already there — true no-op + + // Detach the moving id first so the occupancy test below sees the post-move world. + remove(id); + + const bool occupied = !idAt(targetSlot).empty(); + if (occupied) { + // Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one, + // preserving relative order and interior gaps above the target. The moving id then + // takes targetSlot cleanly. + for (auto& e : entries_) + if (e.slot >= targetSlot) ++e.slot; + } + entries_.push_back(Entry{id, targetSlot}); + sortBySlot(); + return true; +} + +void SlotMap::resetDense(const std::vector& ids) { + entries_.clear(); + int slot = 0; + for (const auto& id : ids) { + if (id.empty()) continue; + if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id) + entries_.push_back(Entry{id, slot++}); + } + // Already ascending by construction; no sort needed. +} + +void SlotMap::reconcile(const std::vector& liveIds) { + // Drop markers whose sample left the index. + entries_.erase( + std::remove_if(entries_.begin(), entries_.end(), + [&](const Entry& e) { + return std::find(liveIds.begin(), liveIds.end(), e.id) == + liveIds.end(); + }), + entries_.end()); + // Append live ids that have no mapping yet (out-of-band index growth), in liveIds + // order, each to the next free slot after the current frontier. + for (const auto& id : liveIds) + if (slotOf(id) < 0) append(id); + sortBySlot(); +} + +bool SlotMap::operator==(const SlotMap& o) const { + return entries_ == o.entries_; +} + +SlotMap SlotMap::fromEntries(const std::vector>& pairs) { + SlotMap m; + for (const auto& [id, slot] : pairs) { + if (id.empty() || slot < 0) continue; // drop malformed pair + if (m.slotOf(id) >= 0) continue; // duplicate id: first wins + if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy + m.entries_.push_back(Entry{id, slot}); + } + m.sortBySlot(); + return m; +} + +std::string SlotMap::serialize() const { + // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). + // json::Writer + numToStr are the same emit path the pre-extraction writer used, + // so the fragment is byte-identical. + std::string out; + out += '['; + for (std::size_t i = 0; i < entries_.size(); ++i) { + if (i) out += ','; + json::Writer e(out); + e.keyStr("id", entries_[i].id); + e.keyRaw("slot", json::numToStr(entries_[i].slot)); + } + out += ']'; + return out; +} + +} // namespace reasampler::model diff --git a/src/core/model/slot_map.h b/src/core/model/slot_map.h new file mode 100644 index 0000000..59f550a --- /dev/null +++ b/src/core/model/slot_map.h @@ -0,0 +1,106 @@ +#pragma once +// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: +// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a +// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps +// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty +// first row above an occupied second row). At most one id per slot (a slot is never +// double-occupied) and at most one slot per id (an id sits in exactly one place). +// +// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one +// sample into two banks may sit at different slots, so position is a per-bank display +// concern owned by the bank's membership. bank_model / Sample stay untouched. +// +// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container +// with its own serialize, distinct from the multi-bank registry that carries it. +// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites); +// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement. +// +// PURE: standard library + core/json (serialize) only. + +#include +#include +#include +#include + +namespace reasampler::model { + +class SlotMap { +public: + // The slot an id occupies, or -1 if the id is not mapped. O(N). + int slotOf(const std::string& id) const; + + // The id occupying `slot`, or "" if the slot is empty. O(N). + std::string idAt(int slot) const; + + // The highest occupied slot, or -1 when the map is empty. Defines the append + // frontier and (with trailing-empty trim) the content extent. + int maxSlot() const; + + // Ids in ASCENDING slot order (the deterministic display order). Empty slots + // produce no entry — the caller iterates occupants; sparse layout is a draw + // concern that reads slotOf/idAt, not this list. + std::vector orderedIds() const; + + // Places `id` at the next free slot after the last occupied one (append). If the + // id is already mapped it is first removed (leaving its old slot empty), then + // appended — an append never fills an earlier gap. No-op guard: empty id ignored. + void append(const std::string& id); + + // Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id + // keeps its position. Returns true if the id was mapped. + bool remove(const std::string& id); + + // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics): + // * target slot EMPTY -> `id` moves there; its old slot is left empty. + // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and + // every occupant at slot >= targetSlot (except `id` itself) shifts up by one, + // preserving their relative order and never colliding. Matches file-manager + // reorder. Interior gaps between shifted occupants are preserved as-is + // (shift is +1 on each occupant, so the gap structure above the target is kept). + // * negative targetSlot is clamped to 0. + // Returns false (no mutation) if `id` is not mapped. Deterministic. + bool reorder(const std::string& id, int targetSlot); + + // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), + // dropping any prior state. The migration path: a pre-L7 bank with no persisted + // slot data is seeded from its BankModel insertion order, densely packed (no gaps), + // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped. + void resetDense(const std::vector& ids); + + // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left + // the index) and appends any live id that has NO mapping yet (a sample the index + // gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps + // the map consistent with the bank's membership without a re-pack. Deterministic: + // orphan appends follow `liveIds` order. + void reconcile(const std::vector& liveIds); + + bool empty() const { return entries_.empty(); } + std::size_t size() const { return entries_.size(); } + + bool operator==(const SlotMap& o) const; + + // JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the + // bank envelope's "slots" member by BankBook::serialize; parsed back by its parser. + // Round-trips losslessly with the rest of the bank. + std::string serialize() const; + + // Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces + // the map invariants defensively against a hand-edited blob: a duplicate id keeps + // its FIRST occurrence; a slot already taken by a kept id drops the later pair + // (never double-occupies); an empty id or negative slot is dropped. The result is + // sorted ascending by slot. reconcile() against live membership runs afterward, so + // a lossy repair here degrades gracefully rather than corrupting lookup. + static SlotMap fromEntries(const std::vector>& pairs); + +private: + struct Entry { + std::string id; + int slot = 0; + bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; } + }; + std::vector entries_; // kept sorted ascending by slot (invariant) + + void sortBySlot(); +}; + +} // namespace reasampler::model diff --git a/src/core/namespaces.h b/src/core/namespaces.h new file mode 100644 index 0000000..fd5431d --- /dev/null +++ b/src/core/namespaces.h @@ -0,0 +1,48 @@ +#pragma once +// core/namespaces.h — Q-W1 INTERIM flat-namespace shim for the not-yet-split +// god/shell TUs (bank_panel / actions / persist / ingest / main / view / capture +// shells / the VST editor+processor). The Q-W1 sub-namespaces move every clean pure +// module's symbols out of the flat `reasampler` namespace; the god modules keep their +// pre-split internals, which reference those symbols unqualified (or qualified as +// `reasampler::X`). Nominating every sub-namespace inside `reasampler` restores both +// forms ([namespace.qual]p2 routes qualified lookup through using-directives), so the +// god internals stay untouched until their own split waves. +// +// SCOPE CONTRACT: included ONLY by god/shell TUs pending their split wave +// (Q-W2/Q-W2v/Q-W3/Q-W4/Q-W5). Clean core modules must NOT include this — they +// reference cross-subsystem symbols by their real namespace homes. Each split wave +// drops this include from the TUs it rewrites; when the last split lands, delete +// this header. + +namespace reasampler { + +namespace model {} +namespace view {} +namespace capture {} +namespace audio {} +namespace ui {} +namespace reclaim {} +namespace version {} +namespace json {} +namespace util {} +namespace wire {} +namespace instrument { +namespace engine {} +namespace map {} +namespace ui {} +} // namespace instrument + +using namespace model; +using namespace view; +using namespace capture; +using namespace audio; +using namespace ui; +using namespace reclaim; +using namespace version; +using namespace util; +using namespace wire; +using namespace instrument::engine; +using namespace instrument::map; +using namespace instrument::ui; + +} // namespace reasampler diff --git a/src/prune_reconcile.cpp b/src/core/reclaim/prune_reconcile.cpp similarity index 97% rename from src/prune_reconcile.cpp rename to src/core/reclaim/prune_reconcile.cpp index d605aad..a9b009b 100644 --- a/src/prune_reconcile.cpp +++ b/src/core/reclaim/prune_reconcile.cpp @@ -1,4 +1,4 @@ -#include "prune_reconcile.h" +#include "core/reclaim/prune_reconcile.h" #include @@ -7,7 +7,7 @@ // keeping a path iff it is owned AND not referenced. Walking `present` (not owned) // gives the ∩-present clause for free and yields output in folder-enumeration order. -namespace reasampler { +namespace reasampler::reclaim { std::vector pruneOrphans(const std::vector& present, const std::vector& referenced, @@ -83,4 +83,4 @@ std::vector pruneDeletePlan(const std::vector& confirm return plan; } -} // namespace reasampler +} // namespace reasampler::reclaim \ No newline at end of file diff --git a/src/prune_reconcile.h b/src/core/reclaim/prune_reconcile.h similarity index 99% rename from src/prune_reconcile.h rename to src/core/reclaim/prune_reconcile.h index a44660c..5d3809d 100644 --- a/src/prune_reconcile.h +++ b/src/core/reclaim/prune_reconcile.h @@ -32,7 +32,7 @@ // -- Path representation: EXACT-STRING match (safety-critical) ----------------- // // Every path in the model is a project-relative string compared VERBATIM: Sample. -// relativePath, OwnedFileManifest::contains (p == relativePath), and BankIndex all +// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all // use raw std::string equality — no separator normalization, no case-folding, no // trailing-slash trimming. This core MATCHES that convention exactly: it compares // the raw strings the shell supplies. Feeding a consistent spelling across the three @@ -46,7 +46,7 @@ #include #include -namespace reasampler { +namespace reasampler::reclaim { // The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin // prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands @@ -175,4 +175,4 @@ PruneReport buildPruneReport(const std::vector& orphans, std::vector pruneDeletePlan(const std::vector& confirmed, const std::vector& freshOrphans); -} // namespace reasampler +} // namespace reasampler::reclaim \ No newline at end of file diff --git a/src/action_bar.cpp b/src/core/ui/action_bar.cpp similarity index 98% rename from src/action_bar.cpp rename to src/core/ui/action_bar.cpp index 3f7535c..65f197d 100644 --- a/src/action_bar.cpp +++ b/src/core/ui/action_bar.cpp @@ -1,10 +1,10 @@ // action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor. -#include "action_bar.h" +#include "core/ui/action_bar.h" #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -151,4 +151,4 @@ int hitTestActionBar(int px, int py, const ActionBarRect& bar, return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/action_bar.h b/src/core/ui/action_bar.h similarity index 96% rename from src/action_bar.h rename to src/core/ui/action_bar.h index 8e48a7e..286d928 100644 --- a/src/action_bar.h +++ b/src/core/ui/action_bar.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's // TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a // thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact @@ -35,7 +36,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // The task cluster a button belongs to (the L2 "group by task" mandate). The order here is // NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum @@ -58,16 +59,7 @@ enum class ActionCluster { // The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the // top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height // band (its own judgment where — above the tail footer, below the split body). -struct ActionBarRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const ActionBarRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using ActionBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // One visible button's placement within the bar, top-left origin. `index` is the button's // position in the caller's flat action list (the caller supplies actions in cluster order, so @@ -156,4 +148,4 @@ std::vector computeBarSlots(const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/bank_grid.cpp b/src/core/ui/bank_grid.cpp similarity index 98% rename from src/bank_grid.cpp rename to src/core/ui/bank_grid.cpp index 1cc3a53..371296b 100644 --- a/src/bank_grid.cpp +++ b/src/core/ui/bank_grid.cpp @@ -1,11 +1,11 @@ // bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor. -#include "bank_grid.h" +#include "core/ui/bank_grid.h" #include #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -224,4 +224,4 @@ float compressAmplitudeForDisplay(float linear) { return linear < 0.0f ? -clamped : clamped; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/bank_grid.h b/src/core/ui/bank_grid.h similarity index 97% rename from src/bank_grid.h rename to src/core/ui/bank_grid.h index 3616e96..c744208 100644 --- a/src/bank_grid.h +++ b/src/core/ui/bank_grid.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // bank_grid — the REAPER-free layout math and cache-key logic behind the docked // bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window, // LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What @@ -14,22 +15,13 @@ #include #include -namespace reasampler { +namespace reasampler::ui { // A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the cell extents. // These are the draw bounds for one sample's thumbnail; the panel draws its // waveform envelope inside this rect (minus any internal padding it applies). -struct CellRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const CellRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using CellRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the // TARGET cell size; the layout fits as many whole columns as the panel width @@ -183,4 +175,4 @@ constexpr float kDisplayFloorDb = -60.0f; // (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f. float compressAmplitudeForDisplay(float linear); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/card_drag.cpp b/src/core/ui/card_drag.cpp similarity index 97% rename from src/card_drag.cpp rename to src/core/ui/card_drag.cpp index afe5971..6289d64 100644 --- a/src/card_drag.cpp +++ b/src/core/ui/card_drag.cpp @@ -1,8 +1,8 @@ // card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor. -#include "card_drag.h" +#include "core/ui/card_drag.h" -namespace reasampler { +namespace reasampler::ui { namespace { @@ -93,4 +93,4 @@ int hitTestSlot(int px, int py, const std::vector& rects) { return -1; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/card_drag.h b/src/core/ui/card_drag.h similarity index 98% rename from src/card_drag.h rename to src/core/ui/card_drag.h index ef0681b..7ce7a9d 100644 --- a/src/card_drag.h +++ b/src/core/ui/card_drag.h @@ -30,10 +30,10 @@ #include -#include "bank_grid.h" // CellRect -#include "drag_out.h" // PanelClientRect, DragState +#include "core/ui/bank_grid.h" // CellRect +#include "core/ui/drag_out.h" // PanelClientRect, DragState -namespace reasampler { +namespace reasampler::ui { // Which drop region the pointer currently sits over WITHIN the client rect. The shell // classifies the live pointer against its own region geometry (tab strip / other bank @@ -144,4 +144,4 @@ std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, // callers reason in model slots. int hitTestSlot(int px, int py, const std::vector& rects); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/card_meta.cpp b/src/core/ui/card_meta.cpp similarity index 96% rename from src/card_meta.cpp rename to src/core/ui/card_meta.cpp index b2e44e9..a52c4e1 100644 --- a/src/card_meta.cpp +++ b/src/core/ui/card_meta.cpp @@ -1,11 +1,11 @@ // card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor. -#include "card_meta.h" +#include "core/ui/card_meta.h" #include #include -namespace reasampler { +namespace reasampler::ui { std::string formatBarsBeats(const MusicalLength& m) { // No derivable musical read-out without a positive tempo AND a stamped meter. @@ -61,4 +61,4 @@ std::string formatSecondsMs(double lengthSeconds) { return buf; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/card_meta.h b/src/core/ui/card_meta.h similarity index 98% rename from src/card_meta.h rename to src/core/ui/card_meta.h index 1c97fad..22cec45 100644 --- a/src/card_meta.h +++ b/src/core/ui/card_meta.h @@ -11,7 +11,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp): // lengthSeconds — captured length in wall-clock seconds (>= 0). @@ -52,4 +52,4 @@ std::string formatBarsBeats(const MusicalLength& m); // * negative length is clamped to "0.000" (a length is never negative; defensive). std::string formatSecondsMs(double lengthSeconds); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/component_geometry.cpp b/src/core/ui/component_geometry.cpp similarity index 97% rename from src/component_geometry.cpp rename to src/core/ui/component_geometry.cpp index 65c1d0e..6d13d75 100644 --- a/src/component_geometry.cpp +++ b/src/core/ui/component_geometry.cpp @@ -1,9 +1,9 @@ // component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL / // LICE / vendor. Standard library only. -#include "component_geometry.h" +#include "core/ui/component_geometry.h" -namespace reasampler { +namespace reasampler::ui { bool hitTestBox(int px, int py, const KitBox& box) { if (box.empty()) return false; @@ -105,4 +105,4 @@ int waveformColumnCount(const KitBox& box) { return w > 0 ? w : 0; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/component_geometry.h b/src/core/ui/component_geometry.h similarity index 95% rename from src/component_geometry.h rename to src/core/ui/component_geometry.h index ff8e30f..af0419b 100644 --- a/src/component_geometry.h +++ b/src/core/ui/component_geometry.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared // drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle, // and a list row. These are the kit-level primitives that DON'T already have a pure owner: @@ -20,23 +21,12 @@ // PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library // only. Builds and unit-tests without REAPER. Mirror of mode_switch / prune_button. -namespace reasampler { +namespace reasampler::ui { // A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit // component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the // same graceful-suppression convention prune_button uses. -struct KitBox { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const KitBox& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using KitBox = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) — // the same discipline as every sibling hit-test so draw and hit-test never double-claim a @@ -136,4 +126,4 @@ int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCou // identical whether bins == columns or bins == k*columns) and wastes memory and CPU. int waveformColumnCount(const KitBox& box); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/drag_out.cpp b/src/core/ui/drag_out.cpp similarity index 95% rename from src/drag_out.cpp rename to src/core/ui/drag_out.cpp index db58c65..54018a7 100644 --- a/src/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -1,10 +1,10 @@ // drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor. -#include "drag_out.h" +#include "core/ui/drag_out.h" #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -51,4 +51,4 @@ PathList assemblePathList(const std::vector& resolved) { return out; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/drag_out.h b/src/core/ui/drag_out.h similarity index 96% rename from src/drag_out.h rename to src/core/ui/drag_out.h index eacf47f..919284b 100644 --- a/src/drag_out.h +++ b/src/core/ui/drag_out.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS // drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are // unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation @@ -29,7 +30,7 @@ #include #include -namespace reasampler { +namespace reasampler::ui { // --- Gesture boundary --------------------------------------------------------- @@ -37,16 +38,7 @@ namespace reasampler { // LICE convention). width/height are the extents; a point (px, py) is INSIDE when // x <= px < x + width and y <= py < y + height (half-open, matching the panel's other // hit-tests so the edge is claimed consistently). -struct PanelClientRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const PanelClientRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using PanelClientRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // The live drag state the shell tracks, reduced to what the boundary decision needs: // whether a drag is currently active (threshold crossed) and whether the armed payload @@ -137,4 +129,4 @@ struct PathList { // case-insensitive dedup on Windows — the pure layer does not guess a platform rule). PathList assemblePathList(const std::vector& resolved); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/footer_bar.cpp b/src/core/ui/footer_bar.cpp similarity index 96% rename from src/footer_bar.cpp rename to src/core/ui/footer_bar.cpp index b1e6824..ca8145a 100644 --- a/src/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -1,8 +1,8 @@ // footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor. -#include "footer_bar.h" +#include "core/ui/footer_bar.h" -namespace reasampler { +namespace reasampler::ui { namespace { @@ -66,4 +66,4 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) { return FooterHit::None; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/footer_bar.h b/src/core/ui/footer_bar.h similarity index 88% rename from src/footer_bar.h rename to src/core/ui/footer_bar.h index bc24342..8f4e15e 100644 --- a/src/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 // footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count // label, and the Tail button, laid out left-to-right at the footer's left. The panel shell @@ -24,32 +25,21 @@ // tiling and hit-test, so mode_switch stays the ONE owner of segment geometry. footer_bar // decides the toggle's placement + overall width; mode_switch subdivides it. // -// NAME NOTE (brief §name-collision): ButtonRect / FooterRect / SegmentRect / ActionBarRect / -// KitBox / KitButtonBox are already owned in this namespace; grep-checked FooterBar* / FooterHit -// FREE before minting. FooterRect (prune_button) is the input strip type and is REUSED here -// (same concept — the footer strip); the new output/spec/hit types carry the FooterBar* prefix. +// Naming: the rect-role family (ButtonRect / FooterRect / FooterBarRect / ...) is unified on +// the ONE concrete ui::Rect (core/ui/rect.h, Q-W1 T2-05) — the per-role names are aliases, so +// the former hand-collision bookkeeping is retired. FooterRect (prune_button) remains the +// shared input-strip spelling; this module's output/spec/hit types carry the FooterBar* prefix. // // PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. -#include "prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted) +#include "core/ui/prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted) -namespace reasampler { +namespace reasampler::ui { // One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect // (empty()) means "not placed" (the footer was too narrow to host it after the ones before it), // so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button. -struct FooterBarRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const FooterBarRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using FooterBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail // button box, in left-to-right order. Any box may be empty (suppressed) when the footer is @@ -113,4 +103,4 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& // mode_switch over the toggle box), then the Tail hit; this returns which region was struck. FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/mode_enable.cpp b/src/core/ui/mode_enable.cpp similarity index 77% rename from src/mode_enable.cpp rename to src/core/ui/mode_enable.cpp index 84df3ce..fc03d7b 100644 --- a/src/mode_enable.cpp +++ b/src/core/ui/mode_enable.cpp @@ -1,10 +1,10 @@ // mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor. -#include "mode_enable.h" +#include "core/ui/mode_enable.h" -#include "view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids +#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids -namespace reasampler { +namespace reasampler::ui { bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { // The target's own mode id, so the rule is a single "target != active" compare. @@ -18,4 +18,4 @@ bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { return activeModeId != targetId; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/mode_enable.h b/src/core/ui/mode_enable.h similarity index 97% rename from src/mode_enable.h rename to src/core/ui/mode_enable.h index 8391a46..be0be30 100644 --- a/src/mode_enable.h +++ b/src/core/ui/mode_enable.h @@ -17,7 +17,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the // untagged default (returning the selection to Arrange), Design = tagged into the Design mode. @@ -36,4 +36,4 @@ enum class TagTarget { // disable an action the user can still reach), so a future added mode never dead-locks the bar. bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/overflow_menu.cpp b/src/core/ui/overflow_menu.cpp similarity index 94% rename from src/overflow_menu.cpp rename to src/core/ui/overflow_menu.cpp index 04b1d3b..71c0446 100644 --- a/src/overflow_menu.cpp +++ b/src/core/ui/overflow_menu.cpp @@ -1,8 +1,8 @@ // overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor. -#include "overflow_menu.h" +#include "core/ui/overflow_menu.h" -namespace reasampler { +namespace reasampler::ui { int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0; @@ -39,4 +39,4 @@ bool hitTestMenuButton(int px, int py, const MenuButtonRect& button) { py >= button.y && py < button.y + button.height; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/overflow_menu.h b/src/core/ui/overflow_menu.h similarity index 87% rename from src/overflow_menu.h rename to src/core/ui/overflow_menu.h index 88764fc..64bcfcb 100644 --- a/src/overflow_menu.h +++ b/src/core/ui/overflow_menu.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" // overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / // Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small @@ -15,39 +16,19 @@ // Mirror of prune_button / mode_switch. The bar rect type it consumes mirrors action_bar's // ActionBarRect shape but is named distinctly to avoid coupling the two modules. -namespace reasampler { +namespace reasampler::ui { // The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The // shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so // this module stands alone (same shape; deliberate — the two modules are not coupled). -struct MenuBarRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const MenuBarRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using MenuBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // The More button's pixel rectangle within the band, top-left origin. A zero-area rect // (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to // place the button clear of its left inset; the caller must not draw or hit-test it. The // three variants stay reachable via their bindable commands, so a suppressed button is // graceful, not a lost affordance. -struct MenuButtonRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const MenuButtonRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using MenuButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar // metrics; the shell passes its own so draw and hit-test share one source of truth. @@ -84,4 +65,4 @@ MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& s // hit-test agree on the same pixels. An empty button never claims a point (always false). bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/prune_button.cpp b/src/core/ui/prune_button.cpp similarity index 94% rename from src/prune_button.cpp rename to src/core/ui/prune_button.cpp index 424c327..6ac2777 100644 --- a/src/prune_button.cpp +++ b/src/core/ui/prune_button.cpp @@ -1,11 +1,11 @@ -#include "prune_button.h" +#include "core/ui/prune_button.h" // prune_button implementation — right-anchored button placement in the footer strip, // with a left-collision suppression rule. Trivially auditable arithmetic; the safety // property (a suppressed/empty button never claims a click) is a pure predicate tested // outside the DAW. -namespace reasampler { +namespace reasampler::ui { ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec) { if (footer.width <= 0 || footer.height <= 0) return ButtonRect{}; // degenerate footer @@ -36,4 +36,4 @@ bool hitTestPruneButton(int px, int py, const ButtonRect& button) { py >= button.y && py < button.y + button.height; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/prune_button.h b/src/core/ui/prune_button.h similarity index 90% rename from src/prune_button.h rename to src/core/ui/prune_button.h index ff0e414..f482f01 100644 --- a/src/prune_button.h +++ b/src/core/ui/prune_button.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // prune_button — the REAPER-free layout math behind the bank_panel's Prune button // (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's // tail-footer strip that fires the "Prune bank folder" command. The panel shell @@ -24,37 +25,17 @@ // is always reachable via its bindable command, so a hidden button is a graceful // degradation, not a lost affordance. -namespace reasampler { +namespace reasampler::ui { // The footer strip the button is drawn into, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the strip extents. // bank_panel derives this from panelFooter() and passes it here. -struct FooterRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const FooterRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using FooterRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // A button's pixel rectangle within the footer, top-left origin. A zero-area rect // (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place // it, or the footer itself is degenerate; the caller must not draw or hit-test it. -struct ButtonRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const ButtonRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer // metrics; the shell passes its own so draw and hit-test share one source of truth. @@ -98,4 +79,4 @@ ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& s // so a suppressed button cannot be accidentally clicked. bool hitTestPruneButton(int px, int py, const ButtonRect& button); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/core/ui/rect.h b/src/core/ui/rect.h new file mode 100644 index 0000000..92e64bd --- /dev/null +++ b/src/core/ui/rect.h @@ -0,0 +1,56 @@ +#pragma once +// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21). +// +// Before Q-W1 the codebase carried 12+ byte-identical {x, y, width, height} structs +// (ButtonRect / FooterRect / CellRect / KitBox / ...) plus a second LTRB grammar on +// the VST side (editor_geometry's left/top/right/bottom Rect). This is the single +// owner: one CONCRETE type (deliberately NOT a template — the role types differed in +// name only, so a template would model nothing), with per-role aliases at the old +// definition sites so call sites keep their semantic names +// (`using ButtonRect = ui::Rect;`). +// +// Grammar: XYWH storage (the majority grammar — every extension role struct), with +// right()/bottom() accessors and an ltrb() factory so the former LTRB call sites +// convert mechanically. Half-open on both axes: a rect covers +// [x, x+width) × [y, y+height) — the same convention LICE/SWELL RECTs use, and the +// one every hitTest* in the codebase already implements. +// +// PURE MODULE: standard library only. Header-only; behavior is covered by the role +// modules' own test executables (prune_button / footer_bar / bank_grid / ... and the +// instrument-ui suites), which exercise every alias against these semantics. + +namespace reasampler::ui { + +struct Rect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + // Exclusive edges (half-open convention). + int right() const { return x + width; } + int bottom() const { return y + height; } + + // A zero-or-negative-area rect means "not placed / suppressed": the caller must + // not draw or hit-test it (the shared graceful-degradation contract). + bool empty() const { return width <= 0 || height <= 0; } + + // The former LTRB grammar's constructor (editor_geometry and friends): edges in, + // extents stored. right/bottom exclusive, matching right()/bottom(). + static Rect ltrb(int left, int top, int right, int bottom) { + return Rect{left, top, right - left, bottom - top}; + } + + bool operator==(const Rect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } + bool operator!=(const Rect& o) const { return !(*this == o); } +}; + +// True iff (px, py) falls inside r under the half-open convention. An empty rect +// contains nothing, so a suppressed affordance can never claim a click. +inline bool contains(const Rect& r, int px, int py) { + return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height; +} + +} // namespace reasampler::ui diff --git a/src/tab_strip.cpp b/src/core/ui/tab_strip.cpp similarity index 98% rename from src/tab_strip.cpp rename to src/core/ui/tab_strip.cpp index 68c57a2..f3b48d0 100644 --- a/src/tab_strip.cpp +++ b/src/core/ui/tab_strip.cpp @@ -1,10 +1,10 @@ // tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor. -#include "tab_strip.h" +#include "core/ui/tab_strip.h" #include -namespace reasampler { +namespace reasampler::ui { TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset) { @@ -109,4 +109,4 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, return miss; // track dead space (no tab under the point) } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/tab_strip.h b/src/core/ui/tab_strip.h similarity index 95% rename from src/tab_strip.h rename to src/core/ui/tab_strip.h index 7932070..c58809c 100644 --- a/src/tab_strip.h +++ b/src/core/ui/tab_strip.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's // named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the // vertical-split bank window is a LICE-drawn tab strip (one tab per named bank, @@ -16,21 +17,12 @@ #include -namespace reasampler { +namespace reasampler::ui { // The strip the tabs are drawn into, top-left origin (SWELL/LICE convention). // (x, y) is the top-left corner; width/height are the strip extents. The panel // reserves this as a fixed-height band at the top of the named-banks region. -struct TabStripRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const TabStripRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using TabStripRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed // so the strip reads as a uniform segmented control and overflow math stays simple — @@ -132,4 +124,4 @@ struct TabHit { TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/theme.cpp b/src/core/ui/theme.cpp similarity index 99% rename from src/theme.cpp rename to src/core/ui/theme.cpp index 5e4bad3..d947d5c 100644 --- a/src/theme.cpp +++ b/src/core/ui/theme.cpp @@ -1,11 +1,11 @@ // theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor. -#include "theme.h" +#include "core/ui/theme.h" #include #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -190,4 +190,4 @@ double textFloor(TextClass cls) { return cls == TextClass::Body ? 4.5 : 3.0; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/theme.h b/src/core/ui/theme.h similarity index 99% rename from src/theme.h rename to src/core/ui/theme.h index 2137113..4e47916 100644 --- a/src/theme.h +++ b/src/core/ui/theme.h @@ -24,7 +24,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a // LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's @@ -115,4 +115,4 @@ double contrastRatio(const KitColor& a, const KitColor& b); // pair the kit actually draws. double textFloor(TextClass cls); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/tooltip.cpp b/src/core/ui/tooltip.cpp similarity index 95% rename from src/tooltip.cpp rename to src/core/ui/tooltip.cpp index b9d902a..d47848e 100644 --- a/src/tooltip.cpp +++ b/src/core/ui/tooltip.cpp @@ -1,8 +1,8 @@ // tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor. -#include "tooltip.h" +#include "core/ui/tooltip.h" -namespace reasampler { +namespace reasampler::ui { std::string stripActionPrefix(const std::string& fullName, const std::string& prefix) { if (prefix.empty()) return fullName; @@ -50,4 +50,4 @@ TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, return box; } -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/tooltip.h b/src/core/ui/tooltip.h similarity index 98% rename from src/tooltip.h rename to src/core/ui/tooltip.h index 38be963..74c5f5b 100644 --- a/src/tooltip.h +++ b/src/core/ui/tooltip.h @@ -13,7 +13,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not // draw" (degenerate inputs); the caller checks empty() before drawing. @@ -59,4 +59,4 @@ TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, int textW, int textH, int clientW, int clientH, const TooltipSpec& spec); -} // namespace reasampler +} // namespace reasampler::ui \ No newline at end of file diff --git a/src/core/util/clamp01.h b/src/core/util/clamp01.h new file mode 100644 index 0000000..0a141a0 --- /dev/null +++ b/src/core/util/clamp01.h @@ -0,0 +1,14 @@ +#pragma once +// clamp01 — the ONE unit-interval clamp (Q-W1, T4-24). Replaces the per-module +// static copies (master_gain / param_slider / envelope_overlay / reasampler_editor). +// Deliberately the ternary form: comparisons with NaN are false, so a NaN input +// passes through unchanged rather than silently collapsing to a bound — the +// behavior of the majority of the retired copies. +// +// PURE: standard library only (not even that). + +namespace reasampler::util { + +constexpr double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } + +} // namespace reasampler::util diff --git a/src/core/util/file_bytes.cpp b/src/core/util/file_bytes.cpp index 09962e2..3a7b879 100644 --- a/src/core/util/file_bytes.cpp +++ b/src/core/util/file_bytes.cpp @@ -4,7 +4,7 @@ #include -namespace reasampler { +namespace reasampler::util { std::vector readFileBytes(const std::string& path) { std::ifstream f(path, std::ios::binary | std::ios::ate); @@ -18,4 +18,4 @@ std::vector readFileBytes(const std::string& path) { return bytes; } -} // namespace reasampler +} // namespace reasampler::util \ No newline at end of file diff --git a/src/core/util/file_bytes.h b/src/core/util/file_bytes.h index b1ee4bf..16fe9d4 100644 --- a/src/core/util/file_bytes.h +++ b/src/core/util/file_bytes.h @@ -9,11 +9,11 @@ #include #include -namespace reasampler { +namespace reasampler::util { // Reads the whole file at `path` into a byte buffer. Empty on ANY failure — // unopenable, empty file, or short read — so the caller has exactly one // "nothing to work with" branch. std::vector readFileBytes(const std::string& path); -} // namespace reasampler +} // namespace reasampler::util \ No newline at end of file diff --git a/src/app_version.cpp b/src/core/version/app_version.cpp similarity index 98% rename from src/app_version.cpp rename to src/core/version/app_version.cpp index 247c24a..c84d212 100644 --- a/src/app_version.cpp +++ b/src/core/version/app_version.cpp @@ -6,13 +6,13 @@ // parse/compare/classify logic (V1). No #ifdef forks leak beyond this file; the shells // consume the accessors below, so channel identity is one auditable definition. -#include "app_version.h" +#include "core/version/app_version.h" #include #include "version_generated.h" // REASAMPLER_VERSION_STRING + REASAMPLER_CHANNEL_IS_BETA -namespace reasampler { +namespace reasampler::version { namespace { // The one channel predicate every derivation below branches on — the single point the @@ -166,4 +166,4 @@ WritingVersion classifyWritingVersion(const std::string& rawStamp) { return wv; } -} // namespace reasampler +} // namespace reasampler::version \ No newline at end of file diff --git a/src/app_version.h b/src/core/version/app_version.h similarity index 99% rename from src/app_version.h rename to src/core/version/app_version.h index f940e6f..ea04abc 100644 --- a/src/app_version.h +++ b/src/core/version/app_version.h @@ -36,7 +36,7 @@ #include #include -namespace reasampler { +namespace reasampler::version { // --- Channel identity (V4, beta-in-isolation) --------------------------------------- // @@ -199,4 +199,4 @@ struct WritingVersion { // with the raw ext-state read and never has to reason about the cases itself. WritingVersion classifyWritingVersion(const std::string& rawStamp); -} // namespace reasampler +} // namespace reasampler::version \ No newline at end of file diff --git a/src/version_generated.h.in b/src/core/version/version_generated.h.in similarity index 100% rename from src/version_generated.h.in rename to src/core/version/version_generated.h.in diff --git a/src/guid_diff.cpp b/src/core/view/guid_diff.cpp similarity index 94% rename from src/guid_diff.cpp rename to src/core/view/guid_diff.cpp index 9a8253c..be4a2af 100644 --- a/src/guid_diff.cpp +++ b/src/core/view/guid_diff.cpp @@ -1,11 +1,11 @@ // guid_diff implementation — pure set arithmetic for new-content detection. See // guid_diff.h. No REAPER, no SWELL — std only. -#include "guid_diff.h" +#include "core/view/guid_diff.h" #include -namespace reasampler { +namespace reasampler::view { std::vector newGuids(const std::set& previous, const std::set& current) { @@ -41,4 +41,4 @@ void GuidBaseline::reset() { primed_ = false; // next observe() re-baselines (first-poll guard re-armed) } -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/guid_diff.h b/src/core/view/guid_diff.h similarity index 98% rename from src/guid_diff.h rename to src/core/view/guid_diff.h index fc4d4c8..2c7bfc3 100644 --- a/src/guid_diff.h +++ b/src/core/view/guid_diff.h @@ -17,7 +17,7 @@ #include #include -namespace reasampler { +namespace reasampler::view { // The GUIDs present in `current` but absent from `previous` — i.e. new since the // previous poll. Order is the set's ascending order (deterministic; the caller does @@ -59,4 +59,4 @@ private: bool primed_ = false; // false ⇒ next observe() sets the baseline }; -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/lane_keys.cpp b/src/core/view/lane_keys.cpp similarity index 95% rename from src/lane_keys.cpp rename to src/core/view/lane_keys.cpp index 41dcf41..b241ff7 100644 --- a/src/lane_keys.cpp +++ b/src/core/view/lane_keys.cpp @@ -1,10 +1,10 @@ // lane_keys implementation — pure string convention, no REAPER. See lane_keys.h. -#include "lane_keys.h" +#include "core/view/lane_keys.h" #include -namespace reasampler { +namespace reasampler::view { namespace { // Does `s` start with the managed-lane prefix? @@ -48,4 +48,4 @@ bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName) { return !hasManagedPrefix(laneName); } -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/lane_keys.h b/src/core/view/lane_keys.h similarity index 98% rename from src/lane_keys.h rename to src/core/view/lane_keys.h index a2a6446..6799704 100644 --- a/src/lane_keys.h +++ b/src/core/view/lane_keys.h @@ -33,7 +33,7 @@ #include #include -namespace reasampler { +namespace reasampler::view { // The prefix the tool stamps on every lane NAME it mints. A lane name carrying this // prefix is a managed lane the tool created; any other name (or an empty/unnamed lane) @@ -82,4 +82,4 @@ std::optional modeIdFromLaneName(const std::string& laneName); // inputs (I_FREEMODE result, P_LANENAME string) and never re-derives this logic. bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName); -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/mode_switch.cpp b/src/core/view/mode_switch.cpp similarity index 96% rename from src/mode_switch.cpp rename to src/core/view/mode_switch.cpp index 204c481..881af49 100644 --- a/src/mode_switch.cpp +++ b/src/core/view/mode_switch.cpp @@ -1,10 +1,10 @@ // mode_switch — pure implementation. See mode_switch.h. NO REAPER / SWELL / vendor. -#include "mode_switch.h" +#include "core/view/mode_switch.h" #include -namespace reasampler { +namespace reasampler::view { namespace { @@ -61,4 +61,4 @@ int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount) { return -1; } -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/mode_switch.h b/src/core/view/mode_switch.h similarity index 84% rename from src/mode_switch.h rename to src/core/view/mode_switch.h index 4813a8c..07c9ce7 100644 --- a/src/mode_switch.h +++ b/src/core/view/mode_switch.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // mode_switch — the REAPER-free layout math behind the bank_panel's Design-View // mode switch (Phase D, Wave 4 — D5). A segmented control `[ Arrange | Design ]` // (N-mode general, one segment per registered mode) drawn in a fixed-height header @@ -13,35 +14,17 @@ #include -namespace reasampler { +namespace reasampler::view { // The header strip the switch is drawn into, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the strip extents. // The panel reserves this at the top of its client area and offsets the grid below. -struct HeaderRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const HeaderRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using HeaderRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // One segment's pixel rectangle within the header, top-left origin. These are the // draw bounds for one mode's button; the panel draws the mode's display name inside // it and lights it when it is the active mode. -struct SegmentRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const SegmentRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using SegmentRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Divides `header` into `segmentCount` equal segments left-to-right, in the caller's // order (the panel passes modes in ordinal order). Returns exactly segmentCount @@ -63,4 +46,4 @@ std::vector computeSegmentRects(const HeaderRect& header, // the panel drew there. int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount); -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/view_mode_model.cpp b/src/core/view/view_mode_model.cpp similarity index 99% rename from src/view_mode_model.cpp rename to src/core/view/view_mode_model.cpp index c215e56..f2cfbfc 100644 --- a/src/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -1,4 +1,4 @@ -#include "view_mode_model.h" +#include "core/view/view_mode_model.h" #include #include @@ -6,7 +6,7 @@ #include #include "core/json/json.h" -#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention +#include "core/view/lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention // view_mode_model implementation. // @@ -19,6 +19,10 @@ namespace reasampler { +// Q-W1 interim: laneNameForMode lives in reasampler::view now; this god module +// re-namespaces in its own split wave. +using view::laneNameForMode; + // --------------------------------------------------------------------------- // equality // --------------------------------------------------------------------------- diff --git a/src/view_mode_model.h b/src/core/view/view_mode_model.h similarity index 100% rename from src/view_mode_model.h rename to src/core/view/view_mode_model.h diff --git a/src/view_tree.cpp b/src/core/view/view_tree.cpp similarity index 93% rename from src/view_tree.cpp rename to src/core/view/view_tree.cpp index 3f5a966..65b239d 100644 --- a/src/view_tree.cpp +++ b/src/core/view/view_tree.cpp @@ -1,8 +1,8 @@ // view_tree — pure folder-depth walk. See view_tree.h. -#include "view_tree.h" +#include "core/view/view_tree.h" -namespace reasampler { +namespace reasampler::view { FolderTree buildFolderTree(const std::vector& entries) { FolderTree tree; @@ -38,4 +38,4 @@ FolderTree buildFolderTree(const std::vector& entries) { return tree; } -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/view_tree.h b/src/core/view/view_tree.h similarity index 93% rename from src/view_tree.h rename to src/core/view/view_tree.h index f349d84..ad63a91 100644 --- a/src/view_tree.h +++ b/src/core/view/view_tree.h @@ -11,9 +11,9 @@ #include #include -#include "view_mode_model.h" +#include "core/view/view_mode_model.h" -namespace reasampler { +namespace reasampler::view { // One track's contribution to the folder walk, read from REAPER in arrange order. // folderDepth is I_FOLDERDEPTH verbatim: 0 = normal, 1 = folder parent (opens a @@ -30,4 +30,4 @@ struct TrackFolderEntry { // is clamped to empty) so a corrupt/stale project can never fault the shell. FolderTree buildFolderTree(const std::vector& entries); -} // namespace reasampler +} // namespace reasampler::view \ No newline at end of file diff --git a/src/assignment_request.cpp b/src/core/wire/assignment_request.cpp similarity index 92% rename from src/assignment_request.cpp rename to src/core/wire/assignment_request.cpp index bbe5775..5ca645d 100644 --- a/src/assignment_request.cpp +++ b/src/core/wire/assignment_request.cpp @@ -1,10 +1,10 @@ // assignment_request.cpp — see assignment_request.h. Pure: standard library only. -#include "assignment_request.h" +#include "core/wire/assignment_request.h" #include "core/wire/wire.h" -namespace reasampler { +namespace reasampler::wire { namespace { @@ -40,4 +40,4 @@ std::optional decodeAssignmentRequest(const std::string& wire return req; } -} // namespace reasampler +} // namespace reasampler::wire \ No newline at end of file diff --git a/src/assignment_request.h b/src/core/wire/assignment_request.h similarity index 97% rename from src/assignment_request.h rename to src/core/wire/assignment_request.h index 46c1e03..5847405 100644 --- a/src/assignment_request.h +++ b/src/core/wire/assignment_request.h @@ -41,11 +41,11 @@ #include #include -namespace reasampler { +namespace reasampler::wire { // One assignment request: the ingested sample's identity + a monotonic disambiguator. // bankId — the bank the sample was ingested into (the active/target bank). -// sampleId — the ingested Sample's stable id (BankIndex key). +// sampleId — the ingested Sample's stable id (BankModel key). // generation — a monotonic value the reader compares to detect a NEW request. The // writer supplies a unix-epoch-seconds stamp; the reader treats it as an // opaque "did this change?" token, not a wall-clock it interprets. @@ -86,4 +86,4 @@ std::string encodeAssignmentRequest(const AssignmentRequest& req); // silently, never crashing or selecting a nonexistent entry. std::optional decodeAssignmentRequest(const std::string& wire); -} // namespace reasampler +} // namespace reasampler::wire \ No newline at end of file diff --git a/src/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp similarity index 93% rename from src/instrument_drop.cpp rename to src/core/wire/instrument_drop.cpp index 9db7320..36dfd9d 100644 --- a/src/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -2,14 +2,14 @@ // NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer and // the SDK-free UID macros (vst/reasampler_uid.h). -#include "instrument_drop.h" +#include "core/wire/instrument_drop.h" #include -#include "vst/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID -#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) +#include "shell/instrument/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID +#include "core/instrument/map/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) -namespace reasampler { +namespace reasampler::wire { namespace { @@ -106,4 +106,4 @@ bool infoNamesFxHotspot(const std::string& info) { return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx"); } -} // namespace reasampler +} // namespace reasampler::wire \ No newline at end of file diff --git a/src/instrument_drop.h b/src/core/wire/instrument_drop.h similarity index 99% rename from src/instrument_drop.h rename to src/core/wire/instrument_drop.h index 7576114..d44de6d 100644 --- a/src/instrument_drop.h +++ b/src/core/wire/instrument_drop.h @@ -36,7 +36,7 @@ #include #include -namespace reasampler { +namespace reasampler::wire { // The 32-char uppercase-hex class-ID string of THIS build's channel-active ReaSampler 9000 // VST3 class UID — exactly what Steinberg::FUID::toString renders and what a .vstpreset @@ -111,4 +111,4 @@ bool infoNamesFxHotspot(const std::string& info); // its setState expects. Not called by the shell (which uses the .vstpreset image). std::vector instrumentDropStateBytes(const std::string& sampleId); -} // namespace reasampler +} // namespace reasampler::wire \ No newline at end of file diff --git a/src/sample_usage.cpp b/src/core/wire/sample_usage.cpp similarity index 99% rename from src/sample_usage.cpp rename to src/core/wire/sample_usage.cpp index 88d64f0..3f45708 100644 --- a/src/sample_usage.cpp +++ b/src/core/wire/sample_usage.cpp @@ -1,12 +1,12 @@ // sample_usage.cpp — see sample_usage.h. Pure: standard library only. -#include "sample_usage.h" +#include "core/wire/sample_usage.h" #include #include "core/wire/wire.h" -namespace reasampler { +namespace reasampler::wire { namespace { @@ -230,4 +230,4 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper return !nameUpper.empty() && up.find(nameUpper) != std::string::npos; } -} // namespace reasampler +} // namespace reasampler::wire \ No newline at end of file diff --git a/src/sample_usage.h b/src/core/wire/sample_usage.h similarity index 99% rename from src/sample_usage.h rename to src/core/wire/sample_usage.h index 3e90fee..1bef5eb 100644 --- a/src/sample_usage.h +++ b/src/core/wire/sample_usage.h @@ -105,7 +105,7 @@ #include #include -namespace reasampler { +namespace reasampler::wire { // One held capture: the bank sample id (attribution/debugging) + the project-relative // WAV path (the prune-protection payload — compared by EXACT string against the prune @@ -250,4 +250,4 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper // ASCII-only uppercase (shared by the matcher and the shell's needle preparation). std::string toUpperAscii(const std::string& s); -} // namespace reasampler +} // namespace reasampler::wire \ No newline at end of file diff --git a/src/ext_keys.h b/src/ext_keys.h index 674e1f9..7fcb56d 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state // namespace + key names, shared by the extension (writer, via persist.h) and the @@ -15,7 +16,7 @@ // stored state. Changing any of them orphans that state. See persist.h for the // per-key retirement / migration semantics — this header only owns the spellings. -#include "app_version.h" +#include "core/version/app_version.h" namespace reasampler { diff --git a/src/ingest.cpp b/src/ingest.cpp index 376a8f5..250b201 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT @@ -16,18 +17,18 @@ #include #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path) -#include "app_version.h" // channelCommandId / channelActionName -#include "assignment_request.h" // pure (bankId, sampleId, generation) encode -#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex -#include "bank_model.h" // Sample, AddResult, findByHash +#include "core/version/app_version.h" // channelCommandId / channelActionName +#include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode +#include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex +#include "core/model/bank_model.h" // Sample, AddResult, findByHash #include "bank_panel.h" // bankPanelRefresh -#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) -#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) +#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) +#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "persist.h" // ReaSamplerSession -#include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path +#include "core/capture/wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) diff --git a/src/ingest.h b/src/ingest.h index a6b2203..3fcd40d 100644 --- a/src/ingest.h +++ b/src/ingest.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // ingest — the S8 "ingest through the bank" shell (EXTENSION side). // diff --git a/src/persist.cpp b/src/persist.cpp index d553925..dfb3e8f 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -1,4 +1,5 @@ -// persist.cpp — REAPER-facing implementation of the BankIndex <-> project +#include "core/namespaces.h" +// persist.cpp — REAPER-facing implementation of the BankModel <-> project // ext-state bridge (M4). See persist.h for the contract. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -93,11 +94,11 @@ #include #endif -#include "app_version.h" -#include "capture_paths.h" -#include "prune_reconcile.h" -#include "usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) -#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) +#include "core/version/app_version.h" +#include "core/capture/capture_paths.h" +#include "core/reclaim/prune_reconcile.h" +#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) +#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -258,11 +259,11 @@ bool ReaSamplerSession::saveToActiveProject() { // seam so the counter and MarkProjectDirty stay paired. The value is whatever // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so // every content mutation's own save carries the fresh generation the instrument reads. The - // format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree + // format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtBankGenKey, - vst::formatBankGeneration(bankGeneration_).c_str()); + instrument::map::formatBankGeneration(bankGeneration_).c_str()); MarkProjectDirty(static_cast(proj)); return true; @@ -636,7 +637,7 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A // project switch reads THAT project's counter, not the previous one's; an absent/malformed // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. - bankGeneration_ = vst::parseBankGeneration( + bankGeneration_ = instrument::map::parseBankGeneration( proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), kProjExtBankGenKey) : std::string{}); @@ -683,7 +684,7 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // Idempotent, so a fresh empty book is a cheap no-op. book_.reconcileSlots(); - // Project-relative resolution is a READ-time concern: every BankIndex in the book + // Project-relative resolution is a READ-time concern: every BankModel in the book // stores only relative paths (invariant, enforced per-bank at add()), and consumers // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to diff --git a/src/persist.h b/src/persist.h index 52b5cfc..1c9cbb2 100644 --- a/src/persist.h +++ b/src/persist.h @@ -1,12 +1,13 @@ +#include "core/namespaces.h" #pragma once -// persist — the REAPER-facing bridge between the in-memory BankIndex and project +// persist — the REAPER-facing bridge between the in-memory BankModel and project // ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths). // -// Save: serialize the BankIndex JSON -> SetProjExtState under namespace +// Save: serialize the BankModel JSON -> SetProjExtState under namespace // "reasampler" (ext state lives inside the .rpp, so the index travels with the // project for free). // Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory -// BankIndex, then resolve each entry's bank file against the CURRENT project +// BankModel, then resolve each entry's bank file against the CURRENT project // dir (project-relative resolution — a project opened from a new location still // finds its bank). // Save-As: when the project path changes, relocate the physical bank folder so @@ -20,14 +21,14 @@ #include #include -#include "app_version.h" -#include "bank_book.h" -#include "bank_model.h" +#include "core/version/app_version.h" +#include "core/model/bank_book.h" +#include "core/model/bank_model.h" #include "ext_keys.h" -#include "owned_manifest.h" -#include "prune_reconcile.h" -#include "tail_control.h" -#include "view_mode_model.h" +#include "core/model/owned_manifest.h" +#include "core/reclaim/prune_reconcile.h" +#include "core/capture/tail_control.h" +#include "core/view/view_mode_model.h" namespace reasampler { @@ -100,21 +101,21 @@ public: ReaSamplerSession() = default; // The multi-bank book (Phase B): the pool + named banks, each wrapping a - // BankIndex, plus the active-bank id. The action layer (B3) creates / renames / + // BankModel, plus the active-bank id. The action layer (B3) creates / renames / // reorders / deletes banks and moves samples here; the panel (B4) reads it; // persist serializes it under the `banks` key on save and replaces it on load. BankBook& book() { return book_; } const BankBook& book() const { return book_; } - // The capture add-target: the ACTIVE bank's BankIndex (defaults to the pool). + // The capture add-target: the ACTIVE bank's BankModel (defaults to the pool). // The capture path adds a captured Sample through this seam, so a capture lands // in whichever bank is active — the single behavioural change B2 wires in over // M7/M8 (the capture backends are untouched; only the target index moved). The // panel/insert readers that displayed the single index continue to read it here // unchanged; today it resolves to the pool (default active), matching prior // single-bank behaviour, until B3/B4 let the user switch the active bank. - BankIndex& bank() { return book_.activeIndex(); } - const BankIndex& bank() const { return book_.activeIndex(); } + BankModel& bank() { return book_.activeIndex(); } + const BankModel& bank() const { return book_.activeIndex(); } // The in-memory Design-View model. The view/action layer mutates it (tag, // toggle, snapshot); persist serializes it on save and replaces it on project @@ -222,7 +223,7 @@ public: // routing). Non-throwing: every filesystem call uses error_code forms; a per-file // failure (locked, already gone) is recorded and skipped, never thrown across the C ABI. // - // Does NOT modify the BankIndex/book (orphans are unreferenced by definition) and does + // Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does // NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present) // algebra naturally once it is off disk — no persist write, so no undo-point question // and no risk to the referenced/owned safety). Writes NO ext-state at all. diff --git a/src/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp similarity index 99% rename from src/drag_out_win.cpp rename to src/shell/actions/drag_out_win.cpp index e555f24..3aa6e83 100644 --- a/src/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // drag_out_win — OS/COM initiation of native OS drag-out (M11). See drag_out_win.h. // // Windows path (primary): a hand-rolled minimal IDataObject exposing exactly one format, @@ -9,7 +10,7 @@ // Compiled into the reaper_reasampler MODULE. No REAPER API is used here (pure OS/COM); it // is a leaf the bank_panel calls. -#include "drag_out_win.h" +#include "shell/actions/drag_out_win.h" #ifdef _WIN32 diff --git a/src/drag_out_win.h b/src/shell/actions/drag_out_win.h similarity index 98% rename from src/drag_out_win.h rename to src/shell/actions/drag_out_win.h index 04ccb6b..4382c82 100644 --- a/src/drag_out_win.h +++ b/src/shell/actions/drag_out_win.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // drag_out_win — the OS/COM initiation half of native OS drag-out (Milestone 11). The pure // gesture-boundary decision and path-list assembly live in drag_out.*; THIS is the platform diff --git a/src/instrument_drop_win.cpp b/src/shell/actions/instrument_drop_win.cpp similarity index 96% rename from src/instrument_drop_win.cpp rename to src/shell/actions/instrument_drop_win.cpp index 363c9c8..763d3ba 100644 --- a/src/instrument_drop_win.cpp +++ b/src/shell/actions/instrument_drop_win.cpp @@ -1,9 +1,10 @@ +#include "core/namespaces.h" // instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT // REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list). -#include "instrument_drop_win.h" +#include "shell/actions/instrument_drop_win.h" #include #include @@ -13,8 +14,8 @@ #include #include -#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) -#include "instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier +#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) +#include "core/wire/instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier #include "reaper_plugin.h" diff --git a/src/instrument_drop_win.h b/src/shell/actions/instrument_drop_win.h similarity index 99% rename from src/instrument_drop_win.h rename to src/shell/actions/instrument_drop_win.h index 20a65cb..161b900 100644 --- a/src/instrument_drop_win.h +++ b/src/shell/actions/instrument_drop_win.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture // decision lives in drag_out (DragGesture::InstrumentDrop) and the pure payload construction diff --git a/src/capture.cpp b/src/shell/capture/capture.cpp similarity index 99% rename from src/capture.cpp rename to src/shell/capture/capture.cpp index b53cb46..0269c07 100644 --- a/src/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend). // // Compiled into the reaper_reasampler MODULE. Includes @@ -33,7 +34,7 @@ // is the realtime-record backend (M8), which captures the master bus output to a // temp track during playback and never invokes the offline render pipeline. -#include "capture.h" +#include "shell/capture/capture.h" #include #include @@ -42,9 +43,9 @@ #include #include -#include "capture_paths.h" +#include "core/capture/capture_paths.h" #include "core/util/file_bytes.h" -#include "render_settings.h" +#include "core/capture/render_settings.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects diff --git a/src/capture.h b/src/shell/capture/capture.h similarity index 98% rename from src/capture.h rename to src/shell/capture/capture.h index 03b61ca..48190f8 100644 --- a/src/capture.h +++ b/src/shell/capture/capture.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). // @@ -19,8 +20,8 @@ #include #include -#include "bank_model.h" -#include "render_settings.h" // TailMode (pure) — the three-state tail contract +#include "core/model/bank_model.h" +#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract // MediaTrack is forward-declared (like track_guid.h) so this header stays // REAPER-free while RealtimeRecordBackend::begin can take the resolved source diff --git a/src/capture_realtime.cpp b/src/shell/capture/capture_realtime.cpp similarity index 99% rename from src/capture_realtime.cpp rename to src/shell/capture/capture_realtime.cpp index de084e8..21c0612 100644 --- a/src/capture_realtime.cpp +++ b/src/shell/capture/capture_realtime.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend). // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -67,7 +68,7 @@ // Item realtime is deferred (UnsupportedMode): item scope would need per-item take // isolation on top of the tap, which is a separate increment. -#include "capture.h" +#include "shell/capture/capture.h" #include #include @@ -78,12 +79,12 @@ #include #include -#include "capture_paths.h" // hashBytes, deriveBankPaths +#include "core/capture/capture_paths.h" // hashBytes, deriveBankPaths #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "peaks.h" // lastFrameAboveThreshold, AudioSample -#include "realtime_record.h" -#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd -#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate +#include "core/audio/peaks.h" // lastFrameAboveThreshold, AudioSample +#include "core/capture/realtime_record.h" +#include "core/capture/render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects diff --git a/src/insert.cpp b/src/shell/capture/insert.cpp similarity index 97% rename from src/insert.cpp rename to src/shell/capture/insert.cpp index 509200b..d6344dc 100644 --- a/src/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // insert.cpp — REAPER-facing placement shell (M6). See insert.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -30,15 +31,15 @@ // doc-comment says "Set exactly one track selected, deselect all others" — // this is the strongest confirmation we have; flagged for DAW-verification. -#include "insert.h" +#include "shell/capture/insert.h" #include #include #include -#include "bank_model.h" +#include "core/model/bank_model.h" #include "bank_panel.h" -#include "capture_paths.h" +#include "core/capture/capture_paths.h" #include "persist.h" #define REAPERAPI_MINIMAL @@ -130,8 +131,8 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) // necessarily the active/capture-target bank. Fall back to the active bank when // the source id names no bank (defensive). const std::string srcBankId = bankPanelSelectedSourceBankId(); - const BankIndex* srcIndex = session->book().index(srcBankId); - const BankIndex& bank = srcIndex ? *srcIndex : session->bank(); + const BankModel* srcIndex = session->book().index(srcBankId); + const BankModel& bank = srcIndex ? *srcIndex : session->bank(); const Sample* sample = bank.query(id); if (!sample) { result.status = InsertStatus::NothingResolved; return result; } diff --git a/src/insert.h b/src/shell/capture/insert.h similarity index 97% rename from src/insert.h rename to src/shell/capture/insert.h index b61aee9..5c6ea59 100644 --- a/src/insert.h +++ b/src/shell/capture/insert.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // insert — placement of bank samples into the arrange (M6). REAPER-facing shell: // it reads the bank_panel's current selection, resolves each selected sample's @@ -17,7 +18,7 @@ // The header is SDK-free: all REAPER API use lives in insert.cpp. The pure // mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW). -#include "insert_plan.h" +#include "core/capture/insert_plan.h" namespace reasampler { diff --git a/src/item_read.cpp b/src/shell/capture/item_read.cpp similarity index 94% rename from src/item_read.cpp rename to src/shell/capture/item_read.cpp index 40edc65..fb84fa6 100644 --- a/src/item_read.cpp +++ b/src/shell/capture/item_read.cpp @@ -1,9 +1,10 @@ +#include "core/namespaces.h" // item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See // item_read.h. Compiled into the reaper_reasampler MODULE; includes // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that // defines the API pointers — CLAUDE.md §contract). -#include "item_read.h" +#include "shell/capture/item_read.h" #include diff --git a/src/item_read.h b/src/shell/capture/item_read.h similarity index 98% rename from src/item_read.h rename to src/shell/capture/item_read.h index 7bb74e9..1aec24a 100644 --- a/src/item_read.h +++ b/src/shell/capture/item_read.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // item_read — the ONE place a MediaItem* is read for its canonical GUID string and for // the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and diff --git a/src/provenance_shell.cpp b/src/shell/capture/provenance_shell.cpp similarity index 95% rename from src/provenance_shell.cpp rename to src/shell/capture/provenance_shell.cpp index 111ae6c..f0a1319 100644 --- a/src/provenance_shell.cpp +++ b/src/shell/capture/provenance_shell.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -19,13 +20,13 @@ // * CountTracks / GetTrack (track scan) // * guidToString (via track_guid) -#include "provenance_shell.h" +#include "shell/capture/provenance_shell.h" #include -#include "bank_book.h" // BankBook, Bank, BankIndex::all -#include "capture_paths.h" // resolveBankFile, normalizeSlashes -#include "track_guid.h" // guidString — the ONE canonical GUID key formatter +#include "core/model/bank_book.h" // BankBook, Bank, BankModel::all +#include "core/capture/capture_paths.h" // resolveBankFile, normalizeSlashes +#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_TrackFX_GetCount diff --git a/src/provenance_shell.h b/src/shell/capture/provenance_shell.h similarity index 98% rename from src/provenance_shell.h rename to src/shell/capture/provenance_shell.h index e53888b..594002e 100644 --- a/src/provenance_shell.h +++ b/src/shell/capture/provenance_shell.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place. // @@ -19,7 +20,7 @@ #include #include -#include "provenance.h" +#include "core/model/provenance.h" class MediaTrack; class MediaItem; diff --git a/src/track_guid.cpp b/src/shell/capture/track_guid.cpp similarity index 91% rename from src/track_guid.cpp rename to src/shell/capture/track_guid.cpp index 8a0de5b..4a47d0b 100644 --- a/src/track_guid.cpp +++ b/src/shell/capture/track_guid.cpp @@ -1,9 +1,10 @@ +#include "core/namespaces.h" // track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See // track_guid.h. Compiled into the reaper_reasampler MODULE; includes // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU // that defines the API pointers — CLAUDE.md §contract). -#include "track_guid.h" +#include "shell/capture/track_guid.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetTrackGUID diff --git a/src/track_guid.h b/src/shell/capture/track_guid.h similarity index 97% rename from src/track_guid.h rename to src/shell/capture/track_guid.h index f13bac3..0bbadd5 100644 --- a/src/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // string used as a membership-index key. Both the Design View shell (view.cpp) and diff --git a/src/vst/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp similarity index 97% rename from src/vst/reaper_bridge.cpp rename to src/shell/instrument/reaper_bridge.cpp index 0c5dc49..91fa451 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -1,11 +1,12 @@ +#include "core/namespaces.h" // reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin. -#include "reaper_bridge.h" +#include "shell/instrument/reaper_bridge.h" #include -#include "bridge_marshal.h" -#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) +#include "core/instrument/map/bridge_marshal.h" +#include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) #include "ext_keys.h" // kProjExtNamespace (shared wire contract) // The VST3 base types must be included before REAPER's VST3 interface header, which diff --git a/src/vst/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h similarity index 99% rename from src/vst/reaper_bridge.h rename to src/shell/instrument/reaper_bridge.h index 78582de..004c54a 100644 --- a/src/vst/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell: // resolves REAPER API functions by name over the host context and reads the live // "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure). diff --git a/src/vst/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp similarity index 87% rename from src/vst/reasampler_embed.cpp rename to src/shell/instrument/reasampler_embed.cpp index 6e739fd..e3c46bf 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -1,22 +1,23 @@ +#include "core/namespaces.h" // reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell. // Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports // "not supported" and draws nothing. -#include "reasampler_embed.h" +#include "shell/instrument/reasampler_embed.h" #include #include -#include "app_version.h" // vstPluginName (channel-derived embed label, S18) -#include "bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh) -#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) -#include "draw_kit.h" // the L1 draw kit: fillSurface/text (L3) -#include "editor_geometry.h" // Rect (shared with embed_strip) -#include "embed_strip.h" // the pure strip layout + hit-test +#include "core/version/app_version.h" // vstPluginName (channel-derived embed label, S18) +#include "core/instrument/map/bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh) +#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) +#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text (L3) +#include "core/instrument/ui/editor_geometry.h" // Rect (shared with embed_strip) +#include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey -#include "reaper_bridge.h" +#include "shell/instrument/reaper_bridge.h" #include "reasampler_processor.h" -#include "theme.h" // Role / InteractionState / spectralColor (L3) +#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3) // wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which // reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type. @@ -48,7 +49,7 @@ namespace { // (component_geometry). Every embed surface now draws by palette ROLE via the L1 kit, retiring // the local pre-L1 forest-green palette + raw GDI DrawTextA. KitBox toKitBox(const Rect& r) { - return KitBox{r.left, r.top, r.width(), r.height()}; + return KitBox{r.x, r.y, r.width, r.height}; } // A short display name for a bank sample id, from the snapshotted list (the editor's helper, @@ -198,12 +199,12 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { if (map_.zones.empty()) { // No opt-in zones authored: a faint bg/cell band spanning the keymap area so the strip // reads as "present, no zones" — the default single-capture face lives in the editor. - LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(), - layout.keymap.height(), toLice(roleColor(Role::BgCell)), 0.5f, 0); + LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width, + layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0); const std::string label = reasampler::vstPluginName() + // channel-derived (S18) (samples_.empty() ? " (bank empty)" : " (no zones)"); - const Rect labelR{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, - layout.keymap.bottom}; + const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(), + layout.keymap.bottom()); text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left); } else { // Draw each zone as a segment across the keymap span, first-match order (so the painted @@ -214,26 +215,26 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { for (int i = 0; i < static_cast(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[i]; const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); - if (r.width() <= 0) continue; + if (r.width <= 0) continue; const bool sel = (i == selectedZone_); if (sel) { // Static glow halo, then the crisp accent-primary fill. - LICE_FillRect(bmp, r.left - 2, r.top, r.width() + 4, r.height(), + LICE_FillRect(bmp, r.x - 2, r.y, r.width + 4, r.height, toLice(roleColor(Role::AccentHot)), 0.30f, 0); - LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + LICE_FillRect(bmp, r.x, r.y, r.width, r.height, toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); } else { const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0; - LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + LICE_FillRect(bmp, r.x, r.y, r.width, r.height, toLice(spectralColor(t)), 0.65f, 0); } - LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); // Label the segment with the sample name when it is wide enough to read. The // selected (accent-fill) segment draws its label in bg/base for contrast (the // tight text-on-pastel pair, §4); the rest in text/primary. - if (r.width() >= 24) { - const Rect lr{r.left + 3, r.top, r.right - 2, r.bottom}; + if (r.width >= 24) { + const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom()); text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(), Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left); } @@ -242,12 +243,12 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { // The level band: a recessed bg/cell channel with an accent-primary fill following the // live activity level (a direct level follow — the one permitted "motion", §3.5). - if (layout.levelBand.height() > 0) { + if (layout.levelBand.height > 0) { fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed); const double level = processor_ ? processor_->embedActivityLevel() : 0.0; const Rect fill = levelFillRect(layout, level); - if (fill.width() > 0) { - LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(), + if (fill.width > 0) { + LICE_FillRect(bmp, fill.x, fill.y, fill.width, fill.height, toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); } } diff --git a/src/vst/reasampler_embed.h b/src/shell/instrument/reasampler_embed.h similarity index 97% rename from src/vst/reasampler_embed.h rename to src/shell/instrument/reasampler_embed.h index 33a017c..bf4f52f 100644 --- a/src/vst/reasampler_embed.h +++ b/src/shell/instrument/reasampler_embed.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's // IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h + // reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in @@ -38,7 +39,7 @@ #include "pluginterfaces/base/funknown.h" -#include "sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) +#include "core/instrument/map/sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) // REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is // pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host diff --git a/src/vst/reasampler_uid.h b/src/shell/instrument/reasampler_uid.h similarity index 100% rename from src/vst/reasampler_uid.h rename to src/shell/instrument/reasampler_uid.h diff --git a/src/vst/reasampler_vst.h b/src/shell/instrument/reasampler_vst.h similarity index 95% rename from src/vst/reasampler_vst.h rename to src/shell/instrument/reasampler_vst.h index abd5fc8..7e252ea 100644 --- a/src/vst/reasampler_vst.h +++ b/src/shell/instrument/reasampler_vst.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument // (Phase S). One place for the plugin's class UID, name, vendor, and version so the // processor, factory, and editor agree. @@ -21,7 +22,7 @@ #include "pluginterfaces/base/funknown.h" -#include "reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values) +#include "shell/instrument/reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values) namespace reasampler::vst { diff --git a/src/vst/vst_entry.cpp b/src/shell/instrument/vst_entry.cpp similarity index 95% rename from src/vst/vst_entry.cpp rename to src/shell/instrument/vst_entry.cpp index ea49a15..4b32533 100644 --- a/src/vst/vst_entry.cpp +++ b/src/shell/instrument/vst_entry.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class // this module offers (the ReaSampler instrument) via the SDK's factory macros. The // Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and @@ -21,10 +22,10 @@ #include "pluginterfaces/vst/ivstaudioprocessor.h" // kVstAudioEffectClass, PlugType -#include "app_version.h" // vstPluginName / appVersion — the channel-derived identity +#include "core/version/app_version.h" // vstPluginName / appVersion — the channel-derived identity #include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target #include "reasampler_processor.h" -#include "reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) +#include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) // CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel // bit (REASAMPLER_CHANNEL_IS_BETA — the class UID selected in reasampler_vst.h, the filename diff --git a/src/draw_kit.cpp b/src/shell/panel/draw_kit.cpp similarity index 98% rename from src/draw_kit.cpp rename to src/shell/panel/draw_kit.cpp index 4c935fa..bc7587e 100644 --- a/src/draw_kit.cpp +++ b/src/shell/panel/draw_kit.cpp @@ -1,14 +1,15 @@ +#include "core/namespaces.h" // draw_kit — the LICE/SWELL shell half of the drawing kit. See draw_kit.h. // // Compiled into the reaper_reasampler MODULE. SHELL layer: it is the only kit file that // touches LICE + SWELL. All colors come from the pure `theme` module; all geometry from // the pure `component_geometry` module. DAW-verified, not unit-tested. -#include "draw_kit.h" +#include "shell/panel/draw_kit.h" #include -#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure) +#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure) // SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is // provided by the host. Mirrors bank_panel.cpp's include discipline. diff --git a/src/draw_kit.h b/src/shell/panel/draw_kit.h similarity index 96% rename from src/draw_kit.h rename to src/shell/panel/draw_kit.h index ba69dae..594f3e0 100644 --- a/src/draw_kit.h +++ b/src/shell/panel/draw_kit.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // draw_kit — the LICE-facing SHELL half of the shared drawing kit (Phase L, L1). This is // the ONE source of drawing for the whole system: every surface (bank_panel now; the VST @@ -23,9 +24,9 @@ // DOUBLE-BUFFER DISCIPLINE (§3.5 "zero-jank"): every function here draws into the caller's // offscreen LICE_IBitmap; the caller BitBlt's once. Nothing here draws direct-to-DC. -#include "component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws -#include "peaks.h" // Envelope — the waveform primitive's input -#include "theme.h" // Role / InteractionState / KitColor / TextClass +#include "core/ui/component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws +#include "core/audio/peaks.h" // Envelope — the waveform primitive's input +#include "core/ui/theme.h" // Role / InteractionState / KitColor / TextClass // LICE types at the boundary (this is the shell half). LICE_IBitmap is forward-declared // to keep the header light. LICE_pixel is a typedef (unsigned int) — not forward-declarable diff --git a/src/usage_scan.cpp b/src/shell/persist/usage_scan.cpp similarity index 96% rename from src/usage_scan.cpp rename to src/shell/persist/usage_scan.cpp index 49b91d8..416ab52 100644 --- a/src/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune // protection; every decision is in the pure sample_usage module, this TU only reads. // @@ -16,7 +17,7 @@ // * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774) // * guidToString (via track_guid::guidString) -#include "usage_scan.h" +#include "shell/persist/usage_scan.h" #include #include @@ -25,11 +26,11 @@ #include #include -#include "app_version.h" // vstPluginName / vstOutputName (channel name needles) +#include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles) #include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix -#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex -#include "sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) -#include "track_guid.h" // guidString — the ONE canonical GUID key formatter +#include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex +#include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) +#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjExtState diff --git a/src/usage_scan.h b/src/shell/persist/usage_scan.h similarity index 99% rename from src/usage_scan.h rename to src/shell/persist/usage_scan.h index e9fd5d8..21db288 100644 --- a/src/usage_scan.h +++ b/src/shell/persist/usage_scan.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for // the pure core, the fail-safe folds, and the full design note). At prune-scan time it diff --git a/src/view.cpp b/src/shell/view/view.cpp similarity index 99% rename from src/view.cpp rename to src/shell/view/view.cpp index a6c4321..3c6f5fd 100644 --- a/src/view.cpp +++ b/src/shell/view/view.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // view.cpp — REAPER-facing Design View shell (Phase D2). See view.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -8,7 +9,7 @@ // module so it is unit-tested outside the DAW; this file owns only the REAPER // reads/writes and the snapshot-before-park ordering. -#include "view.h" +#include "shell/view/view.h" #include #include @@ -18,10 +19,10 @@ #include #include -#include "item_read.h" -#include "lane_keys.h" -#include "track_guid.h" -#include "view_tree.h" +#include "shell/capture/item_read.h" +#include "core/view/lane_keys.h" +#include "shell/capture/track_guid.h" +#include "core/view/view_tree.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountTracks diff --git a/src/view.h b/src/shell/view/view.h similarity index 98% rename from src/view.h rename to src/shell/view/view.h index ae5feb0..1bf8fa6 100644 --- a/src/view.h +++ b/src/shell/view/view.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" #pragma once // view — the REAPER-facing shell of the Design View feature (Phase D2). It is the // mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/ @@ -24,7 +25,7 @@ #include -#include "view_mode_model.h" +#include "core/view/view_mode_model.h" // REAPER's opaque project handle. Forward-declared to keep this header SDK-free; // the .cpp includes reaper_plugin_functions.h and sees the real class. diff --git a/src/vst/curve_popup.cpp b/src/vst/curve_popup.cpp deleted file mode 100644 index 07ffe53..0000000 --- a/src/vst/curve_popup.cpp +++ /dev/null @@ -1,41 +0,0 @@ -// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes. - -#include "curve_popup.h" - -#include - -namespace reasampler::vst { - -namespace { -int clampDim(int want, int lo, int hi, int windowDim) { - const int clamped = (std::max)(lo, (std::min)(hi, want)); - return (std::min)(clamped, (std::max)(0, windowDim)); -} -} // namespace - -CurvePopupLayout computeCurvePopup(int w, int h) { - CurvePopupLayout out; - const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w); - const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h); - const int left = (w - sheetW) / 2; - const int top = (h - sheetH) / 2; - out.sheet = Rect{left, top, left + sheetW, top + sheetH}; - - const int titleBottom = out.sheet.top + kCurvePopupTitleH; - const int closeTop = out.sheet.top + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2; - out.close = Rect{out.sheet.right - kCurvePopupPad - kCurvePopupCloseSize, closeTop, - out.sheet.right - kCurvePopupPad, closeTop + kCurvePopupCloseSize}; - out.title = Rect{out.sheet.left + kCurvePopupPad, out.sheet.top, - out.close.left - kCurvePopupPad, titleBottom}; - - out.curveBox = Rect{out.sheet.left + kCurvePopupPad, titleBottom + 2, - out.sheet.right - kCurvePopupPad, - out.sheet.bottom - kCurvePopupPad}; - return out; -} - -bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) { - return !contains(layout.sheet, x, y); -} - -} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 1ec3362..0bf74fb 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -1,3 +1,5 @@ +#include "core/namespaces.h" +#include "core/util/clamp01.h" // reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the // ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is // guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. @@ -11,28 +13,28 @@ #include #include -#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry -#include "capture_browser.h" -#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) +#include "core/instrument/ui/browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry +#include "core/instrument/ui/capture_browser.h" +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) -#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) -#include "editor_geometry.h" // Rect, contains +#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) +#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) +#include "core/instrument/ui/editor_geometry.h" // Rect, contains #include "ext_keys.h" -#include "keyboard_strip.h" -#include "master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) -#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3) -#include "note_entry.h" // S12 direct numeric note-entry parse -#include "param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta) -#include "peaks.h" // computeEnvelope -#include "reaper_bridge.h" +#include "core/instrument/ui/keyboard_strip.h" +#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) +#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor (L3) +#include "core/instrument/map/note_entry.h" // S12 direct numeric note-entry parse +#include "core/instrument/ui/param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta) +#include "core/audio/peaks.h" // computeEnvelope +#include "shell/instrument/reaper_bridge.h" #include "reasampler_processor.h" -#include "app_version.h" // vstPluginName (channel-derived editor title band, S18) -#include "sample_map.h" -#include "wav_trim.h" // parseWavLayout, extractFloatFrames -#include "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) -#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) +#include "core/version/app_version.h" // vstPluginName (channel-derived editor title band, S18) +#include "core/instrument/map/sample_map.h" +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames +#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) +#include "core/instrument/ui/waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM @@ -86,7 +88,7 @@ constexpr Role kRoleLoopMarker = Role::AccentTertiary; // through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette + // GDI DrawTextA path. KitBox toKitBox(const Rect& r) { - return KitBox{r.left, r.top, r.width(), r.height()}; + return KitBox{r.x, r.y, r.width, r.height}; } // Kit text in a palette ROLE (the common case). Left/Right/Center via Align. @@ -400,7 +402,6 @@ constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceili constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) -double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } } // namespace double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { @@ -952,12 +953,12 @@ struct SampleBands { SampleBands computeSampleBands(int w, int h, int deckH) { SampleBands b; const int titleH = (std::min)(kTitleHeight, h); - b.title = Rect{0, 0, w, titleH}; + b.title = Rect::ltrb(0, 0, w, titleH); // Two nav buttons right-anchored in the title band (Browse then Zone). const int navTop = 2; const int navBot = (std::max)(navTop, titleH - 2); - const Rect zone{w - kPad - kNavButtonWidth, navTop, w - kPad, navBot}; - const Rect browse{zone.left - 4 - kNavButtonWidth, navTop, zone.left - 4, navBot}; + const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot); + const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot); b.navBrowse = browse; b.navZone = zone; @@ -969,9 +970,9 @@ SampleBands computeSampleBands(int w, int h, int deckH) { clusterTop = heroBottom + 4; deckTop = clusterTop + kClusterHeight + 4; } - b.hero = Rect{kPad, titleH, w - kPad, heroBottom}; - b.cluster = Rect{0, clusterTop, w, clusterTop + kClusterHeight}; - b.deck = Rect{kPad, deckTop, w - kPad, deckTop + deckH}; + b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom); + b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight); + b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH); return b; } @@ -988,49 +989,49 @@ struct ClusterRects { }; ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono) { ClusterRects r; - const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2; + const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; const int stripBot = stripTop + kStripBandHeight; - const int curveTop = cluster.top + (cluster.height() - kCurveBtnSize) / 2; - r.curveBtn = Rect{chanMono.left - kPad - kCurveBtnSize, curveTop, - chanMono.left - kPad, curveTop + kCurveBtnSize}; - r.velCell = Rect{r.curveBtn.left - kPad - kVelCellW, stripTop, - r.curveBtn.left - kPad, stripBot}; - const int knobLeft = r.velCell.left + (kVelCellW - kDeckKnobSize) / 2; - r.velKnob = Rect{knobLeft, r.velCell.top, knobLeft + kDeckKnobSize, - r.velCell.top + kDeckKnobSize}; - r.velLabel = Rect{r.velCell.left, r.velKnob.bottom, r.velCell.right, r.velCell.bottom}; - r.preview = Rect{r.velCell.left - kPad - kPreviewBtnW, stripTop, - r.velCell.left - kPad, stripBot}; - r.rootStrip = Rect{cluster.left + kPad, stripTop, r.preview.left - kPad, stripBot}; + const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2; + r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop, + chanMono.x - kPad, curveTop + kCurveBtnSize); + r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop, + r.curveBtn.x - kPad, stripBot); + const int knobLeft = r.velCell.x + (kVelCellW - kDeckKnobSize) / 2; + r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + kDeckKnobSize, + r.velCell.y + kDeckKnobSize); + r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom()); + r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop, + r.velCell.x - kPad, stripBot); + r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot); return r; } // The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance // (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula. Rect zonesStripArea(const Rect& content) { - const int stripTop = content.top + 4 + 20 + 12; // addR.bottom + 12 - return Rect{content.left + kPad, stripTop, content.right - kPad, - stripTop + kStripBandHeight}; + const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 + return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, + stripTop + kStripBandHeight); } // The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the // sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + -// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so +// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom() so // the legend top tracks the strip bottom without re-inlining the strip arithmetic here. Rect noteEntryFieldsArea(const Rect& content) { - const int stripBottom = zonesStripArea(content).bottom; - const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8) - return Rect{content.left + 8 + 128, top, content.right - 8, top + 18}; + const int stripBottom = zonesStripArea(content).bottom(); + const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) + return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18); } // The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal // segments left-to-right. An out-of-range index yields an empty rect. Rect noteEntryFieldRect(const Rect& fields, int f) { - if (f < 0 || f > 2 || fields.width() <= 0) return Rect{}; - const int segW = fields.width() / 3; - const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap - const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW; - return Rect{left, fields.top, right, fields.bottom}; + if (f < 0 || f > 2 || fields.width <= 0) return Rect{}; + const int segW = fields.width / 3; + const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap + const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW; + return Rect::ltrb(left, fields.y, right, fields.bottom()); } // The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + @@ -1038,9 +1039,9 @@ Rect noteEntryFieldRect(const Rect& fields, int f) { // Zones mode-content area. Both draw + hit-test use this single formula so they never drift. Rect zonesControlPanel(const Rect& content) { const Rect strip = zonesStripArea(content); - const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap - return Rect{content.left + kPad, panelTop, content.right - kPad, - content.bottom - 4}; + const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap + return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad, + content.bottom() - 4); } // FB2 (R11-F2 parity): the Zone panel's per-zone controls render as the SAME knob deck the @@ -1052,22 +1053,22 @@ Rect zonesControlPanel(const Rect& content) { // drift. Rect zonesDeckArea(const Rect& content) { const Rect panel = zonesControlPanel(content); - return Rect{panel.left, panel.top, panel.right - kCurveBtnSize - kPad, panel.bottom}; + return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); } // The Zone panel's mini curve-preview button (opens the SAME popup editor as the Sample // cluster's button): the cluster's 28px square, right-anchored at the panel top. Rect zonesCurveButton(const Rect& content) { const Rect panel = zonesControlPanel(content); - return Rect{panel.right - kCurveBtnSize, panel.top, panel.right, panel.top + kCurveBtnSize}; + return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize); } // The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and // the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the // Box through this ONE formula, so drawn nodes and grabs can never drift apart. VelocityCurve::Box curveBoxFromRect(const Rect& r) { - return VelocityCurve::Box{r.left + kVelCurveInset, r.top + kVelCurveInset, - (std::max)(0, r.width() - 2 * kVelCurveInset), - (std::max)(0, r.height() - 2 * kVelCurveInset)}; + return VelocityCurve::Box{r.x + kVelCurveInset, r.y + kVelCurveInset, + (std::max)(0, r.width - 2 * kVelCurveInset), + (std::max)(0, r.height - 2 * kVelCurveInset)}; } // The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is @@ -1078,10 +1079,10 @@ constexpr int kChanSegW = 52; constexpr int kChanSegH = 18; struct ChannelToggleRects { Rect mono; Rect stereo; }; ChannelToggleRects channelToggleRects(const Rect& area) { - const int top = area.top + (area.height() - kChanSegH) / 2; - const int right = area.right - kPad; - const Rect stereo{right - kChanSegW, top, right, top + kChanSegH}; - const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH}; + const int top = area.y + (area.height - kChanSegH) / 2; + const int right = area.right() - kPad; + const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH); + const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH); return {mono, stereo}; } @@ -1135,11 +1136,11 @@ void drawKnobFace(LICE_IBitmap* bmp, const Rect& knobRect, double value01, // faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip // so both read as the same spectrum. `stripArea` is the absolute strip rect. void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { - if (stripArea.width() <= 0 || stripArea.height() <= 0) return; - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int sx = stripArea.left; - const int sy = stripArea.top; - const int h = stripArea.height(); + if (stripArea.width <= 0 || stripArea.height <= 0) return; + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; // A pastel spectral column per key. Each key's local x from keyRect; fill from this key's // left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet // backdrop the root/zone marks sit over. S-VIEW-7: OVERLAY the two-tone piano-key pattern — @@ -1149,8 +1150,8 @@ void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { const LICE_pixel darkKey = toLice(roleColor(Role::BgBase)); for (int n = 0; n <= 127; ++n) { const Rect k = keyRect(sl, n); - const int x0 = k.left + sx; - const int x1 = (n < 127) ? keyRect(sl, n + 1).left + sx : stripArea.right; + const int x0 = k.x + sx; + const int x1 = (n < 127) ? keyRect(sl, n + 1).x + sx : stripArea.right(); const int cw = (std::max)(1, x1 - x0); const KitColor hue = spectralColor(static_cast(n) / 127.0); LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); @@ -1164,23 +1165,23 @@ void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { const LICE_pixel tick = toLice(roleColor(Role::LineHairline)); for (int n = 0; n <= 127; n += 12) { const Rect k = keyRect(sl, n); - LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + h, tick, 1.0f, 0, false); + LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); } } // Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC // glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated. void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) { - const int sx = stripArea.left; - const int sy = stripArea.top; - const int h = stripArea.height(); + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; const Rect marker = rootMarkerRect(sl, root); - const int mw = (std::max)(2, marker.width()); + const int mw = (std::max)(2, marker.width); const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary)); const LICE_pixel glow = toLice(roleColor(Role::AccentHot)); // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). - LICE_FillRect(bmp, marker.left + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); - LICE_FillRect(bmp, marker.left + sx, sy, mw, h, accent, 1.0f, 0); + LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); + LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); } } // namespace @@ -1210,7 +1211,7 @@ void ReaSamplerEditor::paint(HDC hdc) { if (dropHintTicks_ > 0) { const int bannerTop = (std::min)(kTitleHeight, h); const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); - Rect banner{0, bannerTop, w, bannerTop + bannerH}; + Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH); // A transient notice, not the live layer — draw it on the accent-tertiary categorical // hue with a dark label so it reads as "attention, not action". fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest); @@ -1227,7 +1228,7 @@ void ReaSamplerEditor::paint(HDC hdc) { namespace { void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) { fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest); - Rect titleText{title.left + 8, title.top, title.right - 8, title.bottom}; + Rect titleText = Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom()); kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary); } } // namespace @@ -1278,7 +1279,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing // at Browse (which is lit above). No hero waveform / controls to draw. if (empty) { - Rect body{bands.hero.left, bands.hero.top, bands.hero.right, bands.deck.bottom}; + Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom()); paintEmptyState(bmp, body); return; } @@ -1293,7 +1294,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { const std::int64_t frames = static_cast(pcm.size()); const Rect waveArea = bands.hero; fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); - if (frames > 0 && waveArea.width() > 0) { + if (frames > 0 && waveArea.width > 0) { // FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact // partition — extra bins produce no visible change. Clamped to frame count below. @@ -1310,7 +1311,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { const int lx = frameToX(waveArea, frames, m.loopStart); const int rx = frameToX(waveArea, frames, m.loopEnd); if (rx > lx) { - LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), + LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height, toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); } } @@ -1320,7 +1321,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { const int mx = frameToX(waveArea, frames, markerFrames[i]); const bool loopMarker = (i != 0); const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; - LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), + LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height, toLice(roleColor(markerRoles[i])), alpha, 0); } @@ -1336,9 +1337,9 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { const ChannelToggleRects chan = channelToggleRects(bands.cluster); const ClusterRects cr = clusterRects(bands.cluster, chan.mono); int root = effectiveRoot(); - if (cr.rootStrip.width() > 0) { + if (cr.rootStrip.width > 0) { drawSpectralStrip(bmp, cr.rootStrip); - const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height()); + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); drawRootMarker(bmp, cr.rootStrip, sl, root); } @@ -1392,7 +1393,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone, std::int64_t frames) { - if (frames <= 0 || waveArea.width() <= 0 || waveArea.height() <= 0) return; + if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return; const double rate = liveSampleRate(); if (rate <= 0.0) return; const double totalSeconds = static_cast(frames) / rate; @@ -1404,14 +1405,14 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA // curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right). const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); for (std::size_t i = 1; i < poly.size(); ++i) { - const int x0 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i - 1].x)); - const int x1 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i].x)); + const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x)); + const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x)); LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); } // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd - // at area.right-1 MUST get handles); the handle SQUARE is additionally clamped inside the + // at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); @@ -1419,21 +1420,21 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); const int r = 3; - const int hx = (std::max)(waveArea.left + r, (std::min)(waveArea.right - 1 - r, v.x)); - const int hy = (std::max)(waveArea.top + r, (std::min)(waveArea.bottom - 1 - r, v.y)); + const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x)); + const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y)); LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); } } void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone) { - if (r.width() <= 0 || r.height() <= 0) return; // defensive (degenerate rect) + if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect) // The bordered box: a panel surface + hairline border, drawn by palette role. No corner // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the // popup is the only host). fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); const VelocityCurve::Box box = curveBoxFromRect(r); @@ -1462,12 +1463,12 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); const LICE_pixel handleWarn = toLice(roleColor(Role::Warn)); // Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin? - const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.left == r.left && - dragCurveRect_.top == r.top) && - (dragCurX_ < r.left - kCurveDragOffMargin || - dragCurX_ > r.right + kCurveDragOffMargin || - dragCurY_ < r.top - kCurveDragOffMargin || - dragCurY_ > r.bottom + kCurveDragOffMargin); + const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x && + dragCurveRect_.y == r.y) && + (dragCurX_ < r.x - kCurveDragOffMargin || + dragCurX_ > r.right() + kCurveDragOffMargin || + dragCurY_ < r.y - kCurveDragOffMargin || + dragCurY_ > r.bottom() + kCurveDragOffMargin); for (std::size_t i = 0; i < curve.points().size(); ++i) { const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]); const bool grabbed = (drag_ == DragKind::kCurveNode && @@ -1484,8 +1485,8 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone, const std::vector& descs) { - if (deckArea.width() <= 0 || deckArea.height() <= 0) return; - const DeckLayout dl = layoutDeck(descs, deckArea.left, deckArea.top, deckArea.width()); + if (deckArea.width <= 0 || deckArea.height <= 0) return; + const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width); const ZonePlaySeconds& play = zone.play; const bool isMono = (voiceMode_ == VoiceMode::Mono); const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); @@ -1538,7 +1539,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, for (const DeckGroupLayout& g : dl.groups) { // The fence: a bg/panel box with a hairline border, caption micro-caps left. fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, g.box.left, g.box.top, g.box.width() - 1, g.box.height() - 1, + LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1, hairline, 1.0f, 0); const char* caption = ""; switch (g.id) { @@ -1599,7 +1600,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone) { - if (r.width() <= 0 || r.height() <= 0) return; + if (r.width <= 0 || r.height <= 0) return; // The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone // panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced // in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE @@ -1610,11 +1611,11 @@ void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, hov ? InteractionState::Hover : InteractionState::Rest); const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, toLice(border), 1.0f, 0); + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0); const VelocityCurve& curve = zone.velocityCurve; const int inset = 3; - const VelocityCurve::Box mini{r.left + inset, r.top + inset, r.width() - 2 * inset, - r.height() - 2 * inset}; + const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset, + r.height - 2 * inset}; if (mini.width > 1 && mini.height > 1) { const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); int prevX = 0, prevY = 0; @@ -1635,8 +1636,8 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0); const CurvePopupLayout pl = computeCurvePopup(w, h); fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, pl.sheet.left, pl.sheet.top, pl.sheet.width() - 1, - pl.sheet.height() - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); + LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1, + pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); { const KitButtonBox box{toKitBox(pl.close)}; @@ -1759,8 +1760,8 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { // Split the area so the primary line sits centered and the S13 ingest affordance sits just // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept // discoverable here regardless of whether a drop ever lands on THIS window. - Rect primary{area.left, area.top, area.right, area.top + area.height() / 2}; - Rect hint{area.left, primary.bottom, area.right, area.bottom}; + Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2); + Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom()); kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim); kitTextCentered(bmp, hint, "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", @@ -1784,18 +1785,18 @@ constexpr int kBrowseFooterH = 30; BrowseModal computeBrowseModal(int w, int h) { BrowseModal m; const int titleH = (std::min)(kTitleHeight, h); - m.title = Rect{0, 0, w, titleH}; - m.back = Rect{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)}; + m.title = Rect::ltrb(0, 0, w, titleH); + m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)); // Search box below the title, spanning the width (searchBoxRect lays it out from 0). const Rect sb = searchBoxRect(w); - m.search = Rect{kPad, titleH, w - kPad, titleH + sb.height()}; - const int footerTop = (std::max)(m.search.bottom, h - kBrowseFooterH); - m.content = Rect{0, m.search.bottom, w, footerTop}; + m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height); + const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH); + m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop); // Footer: Cancel (left) + Load (right). const int fTop = footerTop + 3; const int fBot = (std::max)(fTop, h - 3); - m.cancel = Rect{kPad, fTop, kPad + 90, fBot}; - m.confirm = Rect{w - kPad - 90, fTop, w - kPad, fBot}; + m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot); + m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot); return m; } } // namespace @@ -1823,29 +1824,29 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { : InteractionState::Rest); fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); if (searchFocused_) { - LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1, - searchAbs.height() - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); + LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1, + searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); } { std::string sb = searchQuery_.empty() ? std::string("Search captures...") : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); - Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom}; + Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom()); kitText(bmp, sbText, sb.c_str(), Font::Label, searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); } // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). const Rect browserArea = bm.content; - const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); - const int ox = browserArea.left; - const int oy = browserArea.top; + const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height); + const int ox = browserArea.x; + const int oy = browserArea.y; scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); const int tabCount = static_cast(banks_.size()) + 1; for (int i = 0; i < tabCount; ++i) { Rect t = filterTabRect(bl, tabCount, i); - t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy}; + t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy); const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; const bool active = (i == 0) ? activeFilterBankId_.empty() : (banks_[static_cast(i - 1)].id == activeFilterBankId_); @@ -1867,12 +1868,12 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { Rect content = cardContentRect(bl, i); Rect thumb = cardThumbnailRect(bl, i); Rect labelR = cardLabelRect(bl, i); - content = Rect{content.left + ox, content.top + oy - scrollOffset_, - content.right + ox, content.bottom + oy - scrollOffset_}; - thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_, - thumb.right + ox, thumb.bottom + oy - scrollOffset_}; - labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_, - labelR.right + ox, labelR.bottom + oy - scrollOffset_}; + content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_, + content.right() + ox, content.bottom() + oy - scrollOffset_); + thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_, + thumb.right() + ox, thumb.bottom() + oy - scrollOffset_); + labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_, + labelR.right() + ox, labelR.bottom() + oy - scrollOffset_); const SampleChoice& s = visible_[static_cast(i)]; const bool pending = (s.id == browsePendingId_); @@ -1883,13 +1884,13 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) : (loaded ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline)); - LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1, + LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1, toLice(cardBorder), 1.0f, 0); drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); std::string caption = s.displayName.empty() ? s.id : s.displayName; - Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2}; - Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom}; + Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2); + Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom()); kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); std::string badge; if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); @@ -1901,10 +1902,10 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { // Scrollbar thumb. { const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); - if (thumb.height() > 0) { + if (thumb.height > 0) { const bool dragging = (drag_ == DragKind::kScrollThumb); const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); - LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(), + LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height, toLice(tc), 0.8f, 0); } } @@ -1913,7 +1914,7 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. - Rect footer{0, bm.content.bottom, w, h}; + Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h); fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); { const KitButtonBox box{toKitBox(bm.cancel)}; @@ -1935,17 +1936,17 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { namespace { Rect zoneContentArea(int w, int h) { const int titleH = (std::min)(kTitleHeight, h); - return Rect{0, titleH, w, h}; + return Rect::ltrb(0, titleH, w, h); } } // namespace void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { // Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns // to the Sample home on close. - const Rect title{0, 0, w, (std::min)(kTitleHeight, h)}; + const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h)); drawTitleBand(bmp, title, "Zone - keyboard map"); { - const Rect back{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom - 2)}; + const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom() - 2)); const KitButtonBox box{toKitBox(back)}; const InteractionState st = isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; @@ -1957,8 +1958,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip // with one bar per zone. Delete is a small × on the selected zone (keystroke also). - Rect addR{content.left + pad, content.top + 4, content.left + pad + 96, - content.top + 4 + 20}; + Rect addR = Rect::ltrb(content.x + pad, content.y + 4, content.x + pad + 96, + content.y + 4 + 20); { const KitButtonBox box{toKitBox(addR)}; const InteractionState state = @@ -1966,7 +1967,7 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false); } - Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); if (selectedZone_ >= 0) { const KitButtonBox box{toKitBox(delR)}; const InteractionState state = @@ -1981,22 +1982,22 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { // zone is live"); the rest take the categorical secondary hue at low alpha. const Rect stripArea = zonesStripArea(content); drawSpectralStrip(bmp, stripArea); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int sx = stripArea.left; - const int sy = stripArea.top; + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; for (int i = 0; i < static_cast(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[static_cast(i)]; Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); - const int bw = (std::max)(2, bar.width()); + const int bw = (std::max)(2, bar.width); const bool sel = (i == selectedZone_); if (sel) { // Static glow halo behind the live zone, then the crisp accent-primary bar. - LICE_FillRect(bmp, bar.left + sx - 2, sy, bw + 4, stripArea.height(), + LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height, toLice(roleColor(Role::AccentHot)), 0.30f, 0); - LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(), + LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); } else { - LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(), + LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, toLice(roleColor(Role::AccentSecondary)), 0.55f, 0); } } @@ -2004,11 +2005,11 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { // A one-line legend of the selected zone below the strip, with three click-to-type numeric // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it // (entryField_) and typed text commits via parseNoteEntry on Enter. - const int legendTop = stripArea.bottom + 8; - Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18}; + const int legendTop = stripArea.bottom() + 8; + Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18); if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom}, + kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()), sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{}, z.sampleId) .c_str(), @@ -2027,11 +2028,11 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { editing ? InteractionState::Focus : InteractionState::Rest); const KitColor border = editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1, + LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1, toLice(border), 1.0f, 0); std::string cap = std::string(names[f]) + ": " + (editing ? (entryText_ + "_") : vals[f]); - kitText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), + kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(), Font::ValueMono, Role::TextPrimary); } } else if (map_.zones.empty()) { @@ -2077,9 +2078,9 @@ void ReaSamplerEditor::resolveHover(int x, int y) { else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1}; else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1}; else { - const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); - const int bx = x - bm.content.left; - const int by = y - bm.content.top; + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + const int bx = x - bm.content.x; + const int by = y - bm.content.y; const int tabCount = static_cast(banks_.size()) + 1; const int tab = filterTabHitTest(bl, tabCount, bx, by); const int card = (tab >= 0) @@ -2099,11 +2100,11 @@ void ReaSamplerEditor::resolveHover(int x, int y) { if (idx >= 0) h = {HoverKind::kCurveNode, idx}; } } else if (view_ == View::kZone) { - const Rect back{w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)}; + const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, + w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)); const Rect content = zoneContentArea(w, hgt); - Rect addR{content.left + kPad, content.top + 4, content.left + kPad + 96, content.top + 4 + 20}; - Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + Rect addR = Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96, content.y + 4 + 20); + Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); if (contains(back, x, y)) { h = {HoverKind::kBack, -1}; } else if (contains(addR, x, y)) { @@ -2119,8 +2120,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) { const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; const Rect deckArea = zonesDeckArea(content); - const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left, - deckArea.top, deckArea.width()); + const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, + deckArea.y, deckArea.width); const DeckHit dh = hitTestDeck(dl, x, y); if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; } @@ -2147,7 +2148,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) { else if (contains(bands.deck, x, y)) { // A deck knob/toggle under the pointer: knobs light + swap label->value. const DeckLayout dl = - layoutDeck(descs, bands.deck.left, bands.deck.top, bands.deck.width()); + layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width); const DeckHit dh = hitTestDeck(dl, x, y); if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; } @@ -2194,9 +2195,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } searchFocused_ = false; - const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); - const int bx = x - bm.content.left; - const int by = y - bm.content.top; + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + const int bx = x - bm.content.x; + const int by = y - bm.content.y; const int tabCount = static_cast(banks_.size()) + 1; const int tab = filterTabHitTest(bl, tabCount, bx, by); if (tab >= 0) { @@ -2207,9 +2208,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { return; } const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); - if (thumb.height() > 0 && - contains(Rect{thumb.left + bm.content.left, thumb.top + bm.content.top, - thumb.right + bm.content.left, thumb.bottom + bm.content.top}, x, y)) { + if (thumb.height > 0 && + contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y, + thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) { drag_ = DragKind::kScrollThumb; dragStartY_ = y; dragStartScrollOffset_ = scrollOffset_; @@ -2308,8 +2309,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its // clicks (no fall-through to the hero/markers). if (contains(bands.deck, x, y)) { - const DeckLayout dl = layoutDeck(deckDescs, bands.deck.left, bands.deck.top, - bands.deck.width()); + const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y, + bands.deck.width); const DeckHit hit = hitTestDeck(dl, x, y); if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { switch (static_cast(hit.id)) { @@ -2422,9 +2423,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { } // Fenced root strip: grab the root marker (remainder-width since r11). - if (cr.rootStrip.width() > 0) { - const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height()); - const int note = keyAtPoint(sl, x - cr.rootStrip.left, y - cr.rootStrip.top); + if (cr.rootStrip.width > 0) { + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); if (note >= 0) { drag_ = DragKind::kRootMarker; dragStartX_ = x; @@ -2441,13 +2442,13 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // The curve popup is modal over the Zone surface too (FB2) — it owns every click while // open, checked before every Zone affordance (incl. Back). if (handlePopupMouseDown(w, h, x, y)) return; - const Rect back{w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)}; + const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, + w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)); if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } const Rect content = zoneContentArea(w, h); const int pad = 8; - Rect addR{content.left + pad, content.top + 4, content.left + pad + 96, - content.top + 4 + 20}; + Rect addR = Rect::ltrb(content.x + pad, content.y + 4, content.x + pad + 96, + content.y + 4 + 20); if (contains(addR, x, y)) { // Add a narrow default zone for the picked capture (or the first visible sample as a // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id @@ -2483,7 +2484,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { commitAndReload(); return; } - Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); if (selectedZone_ >= 0 && contains(delR, x, y)) { map_.zones.erase(map_.zones.begin() + selectedZone_); selectedZone_ = -1; @@ -2494,9 +2495,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the // selected zone's root. const Rect stripArea = zonesStripArea(content); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int lx = x - stripArea.left; - const int ly = y - stripArea.top; + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int lx = x - stripArea.x; + const int ly = y - stripArea.y; std::vector lows, highs; lows.reserve(map_.zones.size()); @@ -2558,8 +2559,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { } const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; const Rect deckArea = zonesDeckArea(content); - const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left, deckArea.top, - deckArea.width()); + const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y, + deckArea.width); const DeckHit hit = hitTestDeck(dl, x, y); if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { // Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete, @@ -2633,7 +2634,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. const ChannelToggleRects chan = channelToggleRects(bands.cluster); const Rect stripArea = clusterRects(bands.cluster, chan.mono).rootStrip; - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); const int note = resolveDragNote(sl, dragStartRoot_, dx); bool found = false; for (int i = 0; i < static_cast(map_.zones.size()); ++i) { @@ -2747,7 +2748,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // at paint from scrollOffset_. const int dyThumb = y - dragStartY_; const BrowseModal bm = computeBrowseModal(w, h); - const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), dragStartScrollOffset_, dyThumb); invalidate(); @@ -2758,7 +2759,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // in the Zone surface where selectedZone_ is set + the strip lives under its content area. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; const Rect stripArea = zonesStripArea(zoneContentArea(w, h)); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; if (drag_ == DragKind::kZoneLow) { z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); @@ -2818,10 +2819,10 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { // move — its amp keeps the last clamped drag value). if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 && curveZone < static_cast(map_.zones.size())) { - const bool off = x < curveRect.left - kCurveDragOffMargin || - x > curveRect.right + kCurveDragOffMargin || - y < curveRect.top - kCurveDragOffMargin || - y > curveRect.bottom + kCurveDragOffMargin; + const bool off = x < curveRect.x - kCurveDragOffMargin || + x > curveRect.right() + kCurveDragOffMargin || + y < curveRect.y - kCurveDragOffMargin || + y > curveRect.bottom() + kCurveDragOffMargin; if (off) { map_.zones[static_cast(curveZone)].velocityCurve.deletePoint( static_cast(curveIdx)); diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 91e0082..03938d8 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000 // capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the // host's IPlugView seat and routes host paint/mouse into the pure geometry modules @@ -30,13 +31,13 @@ #include "public.sdk/source/common/pluginview.h" -#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) -#include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) -#include "envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) -#include "knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2) -#include "peaks.h" // Envelope (the cached peak thumbnail) -#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) -#include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) +#include "core/instrument/ui/editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) +#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) +#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2) +#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) +#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) #ifdef _WIN32 #include diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 3cb03e2..4cce960 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // reasampler_processor.cpp — see reasampler_processor.h. #include "reasampler_processor.h" @@ -17,17 +18,17 @@ #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/vstspeaker.h" -#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) -#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision -#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) +#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) -#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) #include "reasampler_editor.h" -#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) -#include "sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) -#include "sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) -#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) +#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) +#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; using namespace Steinberg::Vst; diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 73f0e85..29b3b6c 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -1,3 +1,4 @@ +#include "core/namespaces.h" // reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the // pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio // output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the @@ -35,9 +36,9 @@ #include "public.sdk/source/vst/vstsinglecomponenteffect.h" -#include "reaper_bridge.h" -#include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) -#include "sampler_core.h" +#include "shell/instrument/reaper_bridge.h" +#include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) +#include "core/instrument/engine/sampler_core.h" namespace reasampler::vst { diff --git a/tests/test_action_bar.cpp b/tests/test_action_bar.cpp index cda6d88..7eba7b1 100644 --- a/tests/test_action_bar.cpp +++ b/tests/test_action_bar.cpp @@ -15,13 +15,14 @@ // degenerate/too-narrow bar handled without crash or overlap. // * Resize: no inventory item cut off or overlapping across a representative width range. -#include "../src/action_bar.h" +#include "../src/core/ui/action_bar.h" #include #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp index 0fca97e..2f8c1e3 100644 --- a/tests/test_app_version.cpp +++ b/tests/test_app_version.cpp @@ -5,12 +5,13 @@ // back from ext state (PreVersioning / Unknown / Stamped). The ext-state I/O (persist) and // the show-version action (main) are DAW-verified shell. -#include "../src/app_version.h" +#include "../src/core/version/app_version.h" #include #include using namespace reasampler; +using namespace reasampler::version; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_app_version_padding.cpp b/tests/test_app_version_padding.cpp index 4e89dfd..295cc4d 100644 --- a/tests/test_app_version_padding.cpp +++ b/tests/test_app_version_padding.cpp @@ -25,12 +25,13 @@ // configure_file input in CMakeLists.txt. They are NOT the shipped version: never bump // them on release — their padded shape is the entire point. -#include "../src/app_version.h" +#include "../src/core/version/app_version.h" #include #include using namespace reasampler; +using namespace reasampler::version; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_assignment_request.cpp b/tests/test_assignment_request.cpp index 9dfe12c..45292e6 100644 --- a/tests/test_assignment_request.cpp +++ b/tests/test_assignment_request.cpp @@ -9,12 +9,13 @@ // trailing-garbage input -> nullopt (the reader's "no pending request" fallback hinges // on it). -#include "../src/assignment_request.h" +#include "../src/core/wire/assignment_request.h" #include #include using namespace reasampler; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp index 0e8674c..f72b69f 100644 --- a/tests/test_bank_book.cpp +++ b/tests/test_bank_book.cpp @@ -10,13 +10,14 @@ // pool, set named, invalid id); JSON round-trip lossless (full book); legacy // bank_index → pool migration. -#include "../src/bank_book.h" +#include "../src/core/model/bank_book.h" #include #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -339,9 +340,9 @@ static void testJsonEmptyBookRoundTrip() { } static void testLegacyMigration() { - // A bare legacy bank_index JSON (BankIndex::serialize output — has "samples", no + // A bare legacy bank_index JSON (BankModel::serialize output — has "samples", no // "banks") must promote into the pool: a book of { pool } with zero named banks. - BankIndex legacy; + BankModel legacy; CHECK(legacy.add(sampleWith("old1")) == AddResult::Added); CHECK(legacy.add(sampleWith("old2")) == AddResult::Added); std::string legacyJson = legacy.serialize(); @@ -361,7 +362,7 @@ static void testLegacyMigration() { } // An EMPTY legacy index ("{\"samples\":[]}" style via serialize) also migrates. - BankIndex emptyLegacy; + BankModel emptyLegacy; auto back2 = BankBook::deserialize(emptyLegacy.serialize()); CHECK(back2.has_value()); CHECK(back2 && back2->size() == 1 && back2->pool().index.empty()); @@ -408,7 +409,7 @@ static void testLoadPrefersBanksBlob() { CHECK(src.bank("drums")->index.add(sampleWith("new1")) == AddResult::Added); const std::string banksJson = src.serialize(); - BankIndex stale; + BankModel stale; CHECK(stale.add(sampleWith("stale-old")) == AddResult::Added); const std::string legacyJson = stale.serialize(); @@ -424,7 +425,7 @@ static void testLoadPrefersBanksBlob() { static void testLoadMigratesLegacyWhenNoBanks() { // No `banks` key, a legacy `bank_index` present: migrate into the pool, zero named. - BankIndex legacy; + BankModel legacy; CHECK(legacy.add(sampleWith("l1")) == AddResult::Added); CHECK(legacy.add(sampleWith("l2")) == AddResult::Added); const std::string legacyJson = legacy.serialize(); @@ -449,7 +450,7 @@ static void testLoadEmptyWhenNeither() { static void testLoadMalformedBanksDegradesWithoutLegacyFallback() { // A present-but-malformed `banks` blob must degrade to an empty book and must NOT // resurrect the stale legacy key (that would revive superseded single-bank state). - BankIndex stale; + BankModel stale; CHECK(stale.add(sampleWith("stale")) == AddResult::Added); const std::string legacyJson = stale.serialize(); @@ -936,7 +937,7 @@ static void testBankBookSlotsRoundTrip() { static void testMigrationDefaultsToInsertionOrderDense() { // A pre-L7 legacy bank_index blob carries no slot data. On load -> reconcileSlots // seeds dense insertion order (no gaps), so it is visually identical. - BankIndex legacy; + BankModel legacy; CHECK(legacy.add(sampleWith("o1")) == AddResult::Added); CHECK(legacy.add(sampleWith("o2")) == AddResult::Added); CHECK(legacy.add(sampleWith("o3")) == AddResult::Added); diff --git a/tests/test_bank_grid.cpp b/tests/test_bank_grid.cpp index 9d178b8..2c0b07e 100644 --- a/tests/test_bank_grid.cpp +++ b/tests/test_bank_grid.cpp @@ -13,7 +13,7 @@ // keyboard nav (arrow clamp, row moves, shift-extend, partial-last-row clamp, // fresh-panel focus). -#include "../src/bank_grid.h" +#include "../src/core/ui/bank_grid.h" #include #include @@ -21,6 +21,7 @@ #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 5de98db..8111e91 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -6,12 +6,13 @@ // present AND absent), dedup-by-hash collapse, tier filter + tier move, // relative-path invariant, empty-index round-trip, malformed/truncated JSON. -#include "../src/bank_model.h" +#include "../src/core/model/bank_model.h" #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -65,12 +66,12 @@ static Sample minimalSample(const std::string& seed) { } static void testFullFieldRoundTrip() { - BankIndex idx; + BankModel idx; CHECK(idx.add(fullSample("a")) == AddResult::Added); CHECK(idx.add(minimalSample("b")) == AddResult::Added); std::string json = idx.serialize(); - auto back = BankIndex::deserialize(json); + auto back = BankModel::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == idx); @@ -104,7 +105,7 @@ static void testFullFieldRoundTrip() { } static void testDedupByHash() { - BankIndex idx; + BankModel idx; Sample a = fullSample("x"); CHECK(idx.add(a) == AddResult::Added); @@ -128,7 +129,7 @@ static void testDedupByHash() { } static void testTierFilterAndMove() { - BankIndex idx; + BankModel idx; Sample scratch = minimalSample("s"); scratch.tier = Tier::Scratch; Sample archive = fullSample("a"); archive.tier = Tier::Archive; CHECK(idx.add(scratch) == AddResult::Added); @@ -153,7 +154,7 @@ static void testTierFilterAndMove() { } static void testRelativePathInvariant() { - BankIndex idx; + BankModel idx; // POSIX absolute, Windows drive, Windows backslash, UNC — all rejected. const char* absolutes[] = { @@ -184,7 +185,7 @@ static void testRelativePathInvariant() { // bypasses dedup (an in-place refresh is not a new insert). The relative-paths-only // invariant still guards the replacement. static void testUpdateInPlace() { - BankIndex idx; + BankModel idx; CHECK(idx.add(minimalSample("a")) == AddResult::Added); // id "min-a" CHECK(idx.add(minimalSample("b")) == AddResult::Added); // id "min-b" CHECK(idx.add(minimalSample("c")) == AddResult::Added); // id "min-c" @@ -221,10 +222,10 @@ static void testUpdateInPlace() { } static void testEmptyIndexRoundTrip() { - BankIndex idx; + BankModel idx; CHECK(idx.empty()); std::string json = idx.serialize(); - auto back = BankIndex::deserialize(json); + auto back = BankModel::deserialize(json); CHECK(back.has_value()); CHECK(back && back->empty()); CHECK(back && *back == idx); @@ -244,17 +245,17 @@ static void testMalformedJson() { "{\"samples\":[{\"createdTimestamp\":notanumber}]}", }; for (const char* j : bad) { - auto r = BankIndex::deserialize(j); + auto r = BankModel::deserialize(j); CHECK(!r.has_value()); // signaled as nullopt, no crash / UB } // A well-formed empty object deserializes to an empty index (lenient root). - auto ok = BankIndex::deserialize("{}"); + auto ok = BankModel::deserialize("{}"); CHECK(ok.has_value() && ok->empty()); } static void testRemoveAndQuery() { - BankIndex idx; + BankModel idx; CHECK(idx.add(fullSample("1")) == AddResult::Added); CHECK(idx.add(fullSample("2")) == AddResult::Added); CHECK(idx.query("id-1") != nullptr); @@ -267,7 +268,7 @@ static void testRemoveAndQuery() { // Fix 1: drive-relative and bare-drive forms must be rejected by add(). static void testAbsolutePathDriveRelative() { - BankIndex idx; + BankModel idx; // Drive-relative: resolves against the drive's CWD, not the project root. Sample dr = minimalSample("dr"); @@ -295,7 +296,7 @@ static void testAbsolutePathDriveRelative() { static void testUnicodeEscapeDecoding() { // é = U+00E9 → 2-byte UTF-8: 0xC3 0xA9 // JSON: "é" - auto r1 = BankIndex::deserialize( + auto r1 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u1\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\u00e9\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -319,7 +320,7 @@ static void testUnicodeEscapeDecoding() { // 中 = U+4E2D → 3-byte UTF-8: 0xE4 0xB8 0xAD // JSON: "中" - auto r2 = BankIndex::deserialize( + auto r2 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u2\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\u4e2d\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -343,7 +344,7 @@ static void testUnicodeEscapeDecoding() { } // 😀 = U+1F600 → surrogate pair 😀 → 4-byte UTF-8: 0xF0 0x9F 0x98 0x80 - auto r3 = BankIndex::deserialize( + auto r3 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u3\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\uD83D\\uDE00\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -368,7 +369,7 @@ static void testUnicodeEscapeDecoding() { } // Unpaired high surrogate (no following \uDCxx) → nullopt. - auto r4 = BankIndex::deserialize( + auto r4 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u4\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\uD83D\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -384,7 +385,7 @@ static void testUnicodeEscapeDecoding() { // Fix 3: strtoll overflow must reject the value, not clamp it silently. static void testIntegerOverflow() { // A timestamp value that overflows int64_t (> 9223372036854775807). - auto r = BankIndex::deserialize( + auto r = BankModel::deserialize( "{\"samples\":[{\"id\":\"ov1\",\"relativePath\":\"bank/ov.wav\"," "\"displayName\":\"\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -400,7 +401,7 @@ static void testIntegerOverflow() { // Fix 4: out-of-range enum values must reject the sample, not produce invalid enum. static void testEnumRangeValidation() { // tier: 99 is not a valid Tier enumerator. - auto r1 = BankIndex::deserialize( + auto r1 = BankModel::deserialize( "{\"samples\":[{\"id\":\"en1\",\"relativePath\":\"bank/en.wav\"," "\"displayName\":\"\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -413,7 +414,7 @@ static void testEnumRangeValidation() { CHECK(!r1.has_value()); // sourceMode: 99 is not a valid SourceMode enumerator. - auto r2 = BankIndex::deserialize( + auto r2 = BankModel::deserialize( "{\"samples\":[{\"id\":\"en2\",\"relativePath\":\"bank/en.wav\"," "\"displayName\":\"\"," "\"sourceMode\":99,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -442,7 +443,7 @@ static void testLegacyJsonDefaults() { "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\"," "\"provenance\":null,\"createdTimestamp\":0}]}"; - auto r = BankIndex::deserialize(legacy); + auto r = BankModel::deserialize(legacy); CHECK(r.has_value()); if (r) { const Sample* s = r->query("leg1"); @@ -453,7 +454,7 @@ static void testLegacyJsonDefaults() { // Re-serialize is lossless: parsing it again yields an equal index. This // proves the absent fields did not silently gain values on the way out. std::string out = r->serialize(); - auto again = BankIndex::deserialize(out); + auto again = BankModel::deserialize(out); CHECK(again.has_value()); CHECK(again && *again == *r); if (again) { @@ -471,7 +472,7 @@ static void testLegacyJsonDefaults() { // storing a bogus value. static void testSeamFieldBoundaries() { // rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip. - BankIndex idx; + BankModel idx; Sample lo = minimalSample("lo"); lo.contentHash = "h-lo"; lo.rootNote = 0; lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0 @@ -484,7 +485,7 @@ static void testSeamFieldBoundaries() { CHECK(idx.add(hi) == AddResult::Added); CHECK(idx.add(end) == AddResult::Added); - auto back = BankIndex::deserialize(idx.serialize()); + auto back = BankModel::deserialize(idx.serialize()); CHECK(back.has_value()); CHECK(back && *back == idx); if (back) { @@ -514,21 +515,21 @@ static void testSeamFieldBoundaries() { "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\"," "\"provenance\":null,\"createdTimestamp\":0}]}"; - CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); - CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); - CHECK(!BankIndex::deserialize( + CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); + CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); + CHECK(!BankModel::deserialize( std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end - CHECK(!BankIndex::deserialize( + CHECK(!BankModel::deserialize( std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start } // S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier -// moves/filtering, and BankIndex ordering are byte-for-byte unchanged by the +// moves/filtering, and BankModel ordering are byte-for-byte unchanged by the // presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields // but sharing a content hash still collapse; a seam-populated sample tiers exactly // like any other. static void testSeamFieldsAdditiveInvariant() { - BankIndex idx; + BankModel idx; Sample a = fullSample("z"); // has rootNote + loop populated CHECK(idx.add(a) == AddResult::Added); diff --git a/tests/test_bank_sync.cpp b/tests/test_bank_sync.cpp index cdcad2a..4195ecb 100644 --- a/tests/test_bank_sync.cpp +++ b/tests/test_bank_sync.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::bank_sync — no REAPER, no VST3, no framework. +// Standalone tests for reasampler::instrument::map::bank_sync — no REAPER, no VST3, no framework. // The S9 bank-generation change-detection + the S8 assignment-request consume DECISION // (the yes/no maths the instrument's off-audio-thread poll runs). The shell owns the // cadence + side effects; this proves the decision rules without a host. @@ -8,7 +8,7 @@ // (no request / not-newer / non-target / unresolvable-drop / apply), asserting both the // apply flag AND the advanced-marker value so a stale request is never re-evaluated. -#include "../src/vst/bank_sync.h" +#include "../src/core/instrument/map/bank_sync.h" #include #include @@ -16,7 +16,7 @@ #include using namespace reasampler; -using namespace reasampler::vst; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_batch_capture.cpp b/tests/test_batch_capture.cpp index 3cb8e93..f8dbd3b 100644 --- a/tests/test_batch_capture.cpp +++ b/tests/test_batch_capture.cpp @@ -9,13 +9,14 @@ // * batch result aggregation: all-success; partial-failure ORDERING (failed // ordinals reported in unit order); all-failed; single-unit noun singular. -#include "../src/batch_capture.h" +#include "../src/core/capture/batch_capture.h" #include #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index 8c6abbb..926f1d2 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test +// Standalone tests for reasampler::instrument::map::bridge_marshal — no VST3, no REAPER, no test // framework. Same fast assert loop as the sibling pure tests: assert the REAPER // bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing // shell only has to invoke the API. @@ -8,11 +8,12 @@ // (the instrument now parses the bank through the shared bank_book JSON path), so its // cases are gone with it. -#include "../src/vst/bridge_marshal.h" +#include "../src/core/instrument/map/bridge_marshal.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp index 9448f52..a1114cf 100644 --- a/tests/test_browser_scroll.cpp +++ b/tests/test_browser_scroll.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::browser_scroll — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window + // scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser. // @@ -11,13 +11,14 @@ // (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order // and returning every index for an empty query. -#include "../src/vst/browser_scroll.h" +#include "../src/core/instrument/ui/browser_scroll.h" #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -45,7 +46,7 @@ static void testMaxOffsetFitsAndOverflows() { CHECK(scrollMaxOffset(L, L.columns) == 0); // Many rows overflow -> max = content - gridHeight. const int many = L.columns * 20; - const int expect = scrollContentHeight(L, many) - L.grid.height(); + const int expect = scrollContentHeight(L, many) - L.grid.height; CHECK(scrollMaxOffset(L, many) == expect); CHECK(expect > 0); } @@ -67,7 +68,7 @@ static void testVisibleRangeTop() { const VisibleRange vr = visibleCardRange(L, many, 0); CHECK(vr.first == 0); // At offset 0, the last visible row is the one containing (gridH-1). - const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1; + const int expectedLastRow = (L.grid.height - 1) / kBrowserCardHeight + 1; CHECK(vr.last == expectedLastRow * L.columns); } @@ -89,30 +90,30 @@ static void testScrolledCellShiftsUp() { const BrowserLayout L = wideLayout(); const Rect base = cardCellRect(L, 3); const Rect shifted = scrolledCardCellRect(L, 3, 40); - CHECK(shifted.top == base.top - 40); - CHECK(shifted.bottom == base.bottom - 40); - CHECK(shifted.left == base.left); + CHECK(shifted.y == base.y - 40); + CHECK(shifted.bottom() == base.bottom() - 40); + CHECK(shifted.x == base.x); } // --- scrollbar thumb ---------------------------------------------------------- static void testThumbEmptyWhenFits() { const BrowserLayout L = wideLayout(); - CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb + CHECK(scrollThumbRect(L, L.columns, 0).height == 0); // one row fits -> no thumb } static void testThumbProportionalAndClamped() { const BrowserLayout L = wideLayout(); const int many = L.columns * 20; const Rect atTop = scrollThumbRect(L, many, 0); - CHECK(atTop.height() > 0); - CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top - CHECK(atTop.width() == kScrollbarWidth); - CHECK(atTop.right == L.grid.right); + CHECK(atTop.height > 0); + CHECK(atTop.y == L.grid.y); // at offset 0 the thumb starts at the track top + CHECK(atTop.width == kScrollbarWidth); + CHECK(atTop.right() == L.grid.right()); // At max offset, the thumb bottom reaches the grid bottom (pinned to the end). const int maxOff = scrollMaxOffset(L, many); const Rect atMax = scrollThumbRect(L, many, maxOff); - CHECK(atMax.bottom == L.grid.top + L.grid.height()); + CHECK(atMax.bottom() == L.grid.y + L.grid.height); } static void testThumbDragIsInverse() { @@ -126,7 +127,7 @@ static void testThumbDragIsInverse() { CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0); // Dragging the thumb by the whole track span from top reaches (near) max. const Rect thumb = scrollThumbRect(L, many, 0); - const int trackSpan = L.grid.height() - thumb.height(); + const int trackSpan = L.grid.height - thumb.height; const int off = thumbDragToOffset(L, many, 0, trackSpan); CHECK(off >= maxOff - 2 && off <= maxOff); } @@ -135,8 +136,8 @@ static void testThumbDragIsInverse() { static void testSearchBoxRect() { const Rect r = searchBoxRect(200); - CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight); - CHECK(searchBoxRect(0).width() == 0); + CHECK(r.x == 0 && r.y == 0 && r.right() == 200 && r.height == kSearchBoxHeight); + CHECK(searchBoxRect(0).width == 0); } static void testNameMatch() { diff --git a/tests/test_capture_browser.cpp b/tests/test_capture_browser.cpp index 9f8a7a0..9c0239e 100644 --- a/tests/test_capture_browser.cpp +++ b/tests/test_capture_browser.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::capture_browser — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::capture_browser — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests (embed_strip / editor_geometry): assert // the capture-first browser's card-grid + bank-filter-tab layout and hit-testing directly. // @@ -10,11 +10,12 @@ // the strip into equal segments with the last tab absorbing the remainder; filterTabHitTest // hitting each tab and missing off-strip. -#include "../src/vst/capture_browser.h" +#include "../src/core/instrument/ui/capture_browser.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -25,12 +26,12 @@ static int g_fail = 0; static void testLayoutNormalArea() { // Wide enough for several columns of the fixed-width card. const BrowserLayout L = layoutBrowser(560, 300); - CHECK(L.tabStrip.left == 0 && L.tabStrip.top == 0 && L.tabStrip.right == 560); - CHECK(L.tabStrip.height() == kBrowserTabHeight); + CHECK(L.tabStrip.x == 0 && L.tabStrip.y == 0 && L.tabStrip.right() == 560); + CHECK(L.tabStrip.height == kBrowserTabHeight); // The grid starts right below the tab strip and fills the rest, contiguous. - CHECK(L.grid.top == L.tabStrip.bottom); - CHECK(L.grid.bottom == 300 && L.grid.right == 560); - // columns = grid.width() / cardWidth (>= 1). + CHECK(L.grid.y == L.tabStrip.bottom()); + CHECK(L.grid.bottom() == 300 && L.grid.right() == 560); + // columns = grid.width / cardWidth (>= 1). CHECK(L.columns == 560 / kBrowserCardWidth); CHECK(L.columns >= 1); } @@ -39,22 +40,22 @@ static void testLayoutNarrowAreaSingleColumn() { // Narrower than one card: still a single column, no inversion. const BrowserLayout L = layoutBrowser(kBrowserCardWidth - 10, 200); CHECK(L.columns == 1); - CHECK(L.grid.width() >= 0); - CHECK(L.tabStrip.height() == kBrowserTabHeight); + CHECK(L.grid.width >= 0); + CHECK(L.tabStrip.height == kBrowserTabHeight); } static void testLayoutZeroArea() { const BrowserLayout L = layoutBrowser(0, 0); - CHECK(L.tabStrip.width() == 0 && L.tabStrip.height() == 0); - CHECK(L.grid.width() == 0); + CHECK(L.tabStrip.width == 0 && L.tabStrip.height == 0); + CHECK(L.grid.width == 0); CHECK(L.columns == 1); // never zero (avoids a divide-by-zero in card layout) } static void testLayoutTinyHeightClampsTabStrip() { // A height below the tab band: the tab strip clamps to the area, the grid is empty. const BrowserLayout L = layoutBrowser(560, kBrowserTabHeight - 6); - CHECK(L.tabStrip.height() == kBrowserTabHeight - 6); - CHECK(L.grid.height() <= 0); // no room left for cards + CHECK(L.tabStrip.height == kBrowserTabHeight - 6); + CHECK(L.grid.height <= 0); // no room left for cards } // --- card rects --------------------------------------------------------------- @@ -64,32 +65,32 @@ static void testCardCellsTileRowMajor() { const int cols = L.columns; // Card 0 is top-left of the grid. const Rect c0 = cardCellRect(L, 0); - CHECK(c0.left == L.grid.left && c0.top == L.grid.top); - CHECK(c0.width() == kBrowserCardWidth && c0.height() == kBrowserCardHeight); + CHECK(c0.x == L.grid.x && c0.y == L.grid.y); + CHECK(c0.width == kBrowserCardWidth && c0.height == kBrowserCardHeight); // Card 1 is one card-width to the right, same row. const Rect c1 = cardCellRect(L, 1); - CHECK(c1.left == L.grid.left + kBrowserCardWidth); - CHECK(c1.top == c0.top); + CHECK(c1.x == L.grid.x + kBrowserCardWidth); + CHECK(c1.y == c0.y); // The first card of the SECOND row wraps back to the left, one card-height down. const Rect wrap = cardCellRect(L, cols); - CHECK(wrap.left == L.grid.left); - CHECK(wrap.top == L.grid.top + kBrowserCardHeight); + CHECK(wrap.x == L.grid.x); + CHECK(wrap.y == L.grid.y + kBrowserCardHeight); } static void testCardCellNegativeIndex() { const BrowserLayout L = layoutBrowser(560, 300); const Rect r = cardCellRect(L, -1); - CHECK(r.left == 0 && r.top == 0 && r.right == 0 && r.bottom == 0); + CHECK(r.x == 0 && r.y == 0 && r.right() == 0 && r.bottom() == 0); } static void testCardContentInsetByGutter() { const BrowserLayout L = layoutBrowser(560, 300); const Rect cell = cardCellRect(L, 0); const Rect content = cardContentRect(L, 0); - CHECK(content.left == cell.left + kBrowserCardGutter); - CHECK(content.top == cell.top + kBrowserCardGutter); - CHECK(content.right == cell.right - kBrowserCardGutter); - CHECK(content.bottom == cell.bottom - kBrowserCardGutter); + CHECK(content.x == cell.x + kBrowserCardGutter); + CHECK(content.y == cell.y + kBrowserCardGutter); + CHECK(content.right() == cell.right() - kBrowserCardGutter); + CHECK(content.bottom() == cell.bottom() - kBrowserCardGutter); } static void testThumbnailAboveLabel() { @@ -98,12 +99,12 @@ static void testThumbnailAboveLabel() { const Rect thumb = cardThumbnailRect(L, 0); const Rect label = cardLabelRect(L, 0); // Thumbnail is the top band of the content; the label is the remainder below it, contiguous. - CHECK(thumb.left == content.left && thumb.right == content.right); - CHECK(thumb.top == content.top); - CHECK(thumb.height() == kBrowserThumbHeight); - CHECK(label.top == thumb.bottom); - CHECK(label.bottom == content.bottom); - CHECK(label.left == content.left && label.right == content.right); + CHECK(thumb.x == content.x && thumb.right() == content.right()); + CHECK(thumb.y == content.y); + CHECK(thumb.height == kBrowserThumbHeight); + CHECK(label.y == thumb.bottom()); + CHECK(label.bottom() == content.bottom()); + CHECK(label.x == content.x && label.right() == content.right()); } // --- cardHitTest -------------------------------------------------------------- @@ -111,8 +112,8 @@ static void testThumbnailAboveLabel() { static void testCardHitCenterOfCard() { const BrowserLayout L = layoutBrowser(560, 300); const Rect content = cardContentRect(L, 3); - const int cx = content.left + content.width() / 2; - const int cy = content.top + content.height() / 2; + const int cx = content.x + content.width / 2; + const int cy = content.y + content.height / 2; CHECK(cardHitTest(L, 12, cx, cy) == 3); } @@ -121,21 +122,21 @@ static void testCardHitMissesGutter() { // A point in the gutter between the content and the cell edge (top-left corner of cell 0) // is a miss — only the card CONTENT counts. const Rect cell = cardCellRect(L, 0); - CHECK(cardHitTest(L, 12, cell.left, cell.top) == -1); + CHECK(cardHitTest(L, 12, cell.x, cell.y) == -1); } static void testCardHitMissesPastLastCard() { const BrowserLayout L = layoutBrowser(560, 300); // Only 2 cards exist; a point on where card 5 WOULD be is a miss. const Rect content = cardContentRect(L, 5); - const int cx = content.left + content.width() / 2; - const int cy = content.top + content.height() / 2; + const int cx = content.x + content.width / 2; + const int cy = content.y + content.height / 2; CHECK(cardHitTest(L, 2, cx, cy) == -1); } static void testCardHitMissesTabStrip() { const BrowserLayout L = layoutBrowser(560, 300); - CHECK(cardHitTest(L, 12, 10, L.tabStrip.top + 2) == -1); + CHECK(cardHitTest(L, 12, 10, L.tabStrip.y + 2) == -1); } static void testCardHitZeroCards() { @@ -150,21 +151,21 @@ static void testFilterTabsTileStrip() { const int n = 4; // "All" + 3 banks const Rect t0 = filterTabRect(L, n, 0); const Rect tLast = filterTabRect(L, n, n - 1); - CHECK(t0.left == L.tabStrip.left); + CHECK(t0.x == L.tabStrip.x); // Adjacent tabs share an exact edge (no gap). - CHECK(filterTabRect(L, n, 0).right == filterTabRect(L, n, 1).left); - CHECK(filterTabRect(L, n, 1).right == filterTabRect(L, n, 2).left); + CHECK(filterTabRect(L, n, 0).right() == filterTabRect(L, n, 1).x); + CHECK(filterTabRect(L, n, 1).right() == filterTabRect(L, n, 2).x); // The last tab reaches the strip's right edge exactly (absorbs the remainder). - CHECK(tLast.right == L.tabStrip.right); + CHECK(tLast.right() == L.tabStrip.right()); // All tabs share the strip's height. - CHECK(t0.top == L.tabStrip.top && t0.bottom == L.tabStrip.bottom); + CHECK(t0.y == L.tabStrip.y && t0.bottom() == L.tabStrip.bottom()); } static void testFilterTabOutOfRange() { const BrowserLayout L = layoutBrowser(560, 300); - CHECK(filterTabRect(L, 3, -1).width() == 0); - CHECK(filterTabRect(L, 3, 3).width() == 0); - CHECK(filterTabRect(L, 0, 0).width() == 0); + CHECK(filterTabRect(L, 3, -1).width == 0); + CHECK(filterTabRect(L, 3, 3).width == 0); + CHECK(filterTabRect(L, 0, 0).width == 0); } static void testFilterTabHit() { @@ -172,12 +173,12 @@ static void testFilterTabHit() { const int n = 3; for (int i = 0; i < n; ++i) { const Rect t = filterTabRect(L, n, i); - const int cx = t.left + t.width() / 2; - const int cy = t.top + t.height() / 2; + const int cx = t.x + t.width / 2; + const int cy = t.y + t.height / 2; CHECK(filterTabHitTest(L, n, cx, cy) == i); } // Below the strip (in the grid) -> no tab. - CHECK(filterTabHitTest(L, n, 20, L.grid.top + 4) == -1); + CHECK(filterTabHitTest(L, n, 20, L.grid.y + 4) == -1); } int main() { diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index aff2b5e..3d253c9 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -1,9 +1,9 @@ // Standalone tests for reasampler::capture_paths — no REAPER, no framework. // The capture shell is DAW-bound and only verifiable in REAPER; this covers the // one genuinely pure piece: the bank-folder / unique-name / project-relative -// path arithmetic that feeds BankIndex::add's relative-only invariant. +// path arithmetic that feeds BankModel::add's relative-only invariant. -#include "../src/capture_paths.h" +#include "../src/core/capture/capture_paths.h" #include #include @@ -12,6 +12,7 @@ #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -74,7 +75,7 @@ static void testDeriveRelativePathIsProjectRelative() { BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "master mix", "1753080000"); // Relative path is under the fixed bank subfolder, forward-slashed, .wav. CHECK(p.relativePath == "reasampler_bank/master_mix_1753080000.wav"); - // It must NOT be absolute by any of BankIndex::add's rejection rules: + // It must NOT be absolute by any of BankModel::add's rejection rules: // no leading '/', no drive letter, no backslash, no UNC prefix. CHECK(p.relativePath.find(':') == std::string::npos); CHECK(p.relativePath.find('\\') == std::string::npos); diff --git a/tests/test_card_drag.cpp b/tests/test_card_drag.cpp index a7018f4..473c6f1 100644 --- a/tests/test_card_drag.cpp +++ b/tests/test_card_drag.cpp @@ -7,11 +7,12 @@ // -> None); cursor-cue mapping (incl. Replace only for Replace); slot rects include empties // (gap layout), dense layout matches a plain grid, slot hit-test returns slot index + miss. -#include "../src/card_drag.h" +#include "../src/core/ui/card_drag.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_card_meta.cpp b/tests/test_card_meta.cpp index 37fcdbf..b94974b 100644 --- a/tests/test_card_meta.cpp +++ b/tests/test_card_meta.cpp @@ -6,12 +6,13 @@ // long capture, non-4/4 meters (3/4 and 6/8), unstamped meter -> blank, unknown tempo -> // blank (s.ms still derivable); s.ms zero / sub-second / multi-second / ms carry / negative. -#include "../src/card_meta.h" +#include "../src/core/ui/card_meta.h" #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_component_geometry.cpp b/tests/test_component_geometry.cpp index 3d97095..a793ac4 100644 --- a/tests/test_component_geometry.cpp +++ b/tests/test_component_geometry.cpp @@ -7,11 +7,12 @@ // row, hover hit-test returns the right row and "no hit" outside/past the last row; and the // shared half-open box hit-test agrees with layout (no double-claimed pixel). -#include "../src/component_geometry.h" +#include "../src/core/ui/component_geometry.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_curve_popup.cpp b/tests/test_curve_popup.cpp index 9e6c5c2..262e560 100644 --- a/tests/test_curve_popup.cpp +++ b/tests/test_curve_popup.cpp @@ -1,14 +1,15 @@ -// Standalone tests for reasampler::vst::curve_popup — no VST3, no REAPER, no framework. Same +// Standalone tests for reasampler::instrument::ui::curve_popup — no VST3, no REAPER, no framework. Same // fast assert loop as the sibling pure tests. Assert the r11 popup-sheet geometry at the size // clamps (the spec's width clamp(60%, 360..520) / height clamp(55%, 260..380)), the centering, // the title-row/close-button placement, the curve-box remainder, and the outside-sheet // dismissal test. -#include "../src/vst/curve_popup.h" +#include "../src/core/instrument/ui/curve_popup.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -17,66 +18,66 @@ static int g_fail = 0; static void testDefaultWindowMidClamp() { // 840x620: 60% = 504 (inside 360..520), 55% = 341 (inside 260..380). const CurvePopupLayout pl = computeCurvePopup(840, 620); - CHECK(pl.sheet.width() == 504); - CHECK(pl.sheet.height() == 341); + CHECK(pl.sheet.width == 504); + CHECK(pl.sheet.height == 341); // Centered (within the integer-division pixel). - CHECK(pl.sheet.left == (840 - 504) / 2); - CHECK(pl.sheet.top == (620 - 341) / 2); + CHECK(pl.sheet.x == (840 - 504) / 2); + CHECK(pl.sheet.y == (620 - 341) / 2); } static void testMinClamp() { // The 560x460 constraint floor: 60% = 336 -> clamps UP to 360; 55% = 253 -> up to 260. const CurvePopupLayout pl = computeCurvePopup(560, 460); - CHECK(pl.sheet.width() == kCurvePopupMinW); - CHECK(pl.sheet.height() == kCurvePopupMinH); - CHECK(pl.sheet.left >= 0 && pl.sheet.right <= 560); - CHECK(pl.sheet.top >= 0 && pl.sheet.bottom <= 460); + CHECK(pl.sheet.width == kCurvePopupMinW); + CHECK(pl.sheet.height == kCurvePopupMinH); + CHECK(pl.sheet.x >= 0 && pl.sheet.right() <= 560); + CHECK(pl.sheet.y >= 0 && pl.sheet.bottom() <= 460); } static void testMaxClamp() { // A large window: 60% of 1600 = 960 -> clamps DOWN to 520; 55% of 900 = 495 -> down to 380. const CurvePopupLayout pl = computeCurvePopup(1600, 900); - CHECK(pl.sheet.width() == kCurvePopupMaxW); - CHECK(pl.sheet.height() == kCurvePopupMaxH); + CHECK(pl.sheet.width == kCurvePopupMaxW); + CHECK(pl.sheet.height == kCurvePopupMaxH); } static void testDegenerateWindowNeverOverhangs() { // A window smaller than the min clamp: the sheet caps at the window dimension (defensive — // below checkSizeConstraint, but geometry must stay sane). const CurvePopupLayout pl = computeCurvePopup(300, 200); - CHECK(pl.sheet.width() == 300); - CHECK(pl.sheet.height() == 200); - CHECK(pl.sheet.left == 0 && pl.sheet.top == 0); + CHECK(pl.sheet.width == 300); + CHECK(pl.sheet.height == 200); + CHECK(pl.sheet.x == 0 && pl.sheet.y == 0); } static void testTitleRowAndCurveBox() { const CurvePopupLayout pl = computeCurvePopup(840, 620); // Close: 18x18, right-anchored inside the title row. - CHECK(pl.close.width() == kCurvePopupCloseSize && pl.close.height() == kCurvePopupCloseSize); - CHECK(pl.close.right == pl.sheet.right - kCurvePopupPad); - CHECK(pl.close.top >= pl.sheet.top); - CHECK(pl.close.bottom <= pl.sheet.top + kCurvePopupTitleH); + CHECK(pl.close.width == kCurvePopupCloseSize && pl.close.height == kCurvePopupCloseSize); + CHECK(pl.close.right() == pl.sheet.right() - kCurvePopupPad); + CHECK(pl.close.y >= pl.sheet.y); + CHECK(pl.close.bottom() <= pl.sheet.y + kCurvePopupTitleH); // Title text: left of the close button, in the title row. - CHECK(pl.title.left == pl.sheet.left + kCurvePopupPad); - CHECK(pl.title.right <= pl.close.left); + CHECK(pl.title.x == pl.sheet.x + kCurvePopupPad); + CHECK(pl.title.right() <= pl.close.x); // Curve box: fills the remainder below the title row, inside the sheet margins. - CHECK(pl.curveBox.top >= pl.sheet.top + kCurvePopupTitleH); - CHECK(pl.curveBox.left == pl.sheet.left + kCurvePopupPad); - CHECK(pl.curveBox.right == pl.sheet.right - kCurvePopupPad); - CHECK(pl.curveBox.bottom == pl.sheet.bottom - kCurvePopupPad); - CHECK(pl.curveBox.width() > 0 && pl.curveBox.height() > 0); + CHECK(pl.curveBox.y >= pl.sheet.y + kCurvePopupTitleH); + CHECK(pl.curveBox.x == pl.sheet.x + kCurvePopupPad); + CHECK(pl.curveBox.right() == pl.sheet.right() - kCurvePopupPad); + CHECK(pl.curveBox.bottom() == pl.sheet.bottom() - kCurvePopupPad); + CHECK(pl.curveBox.width > 0 && pl.curveBox.height > 0); } static void testOutsideSheetDismissTest() { const CurvePopupLayout pl = computeCurvePopup(840, 620); // On the wash: outside. CHECK(popupOutsideSheet(pl, 0, 0)); - CHECK(popupOutsideSheet(pl, pl.sheet.left - 1, pl.sheet.top + 10)); - CHECK(popupOutsideSheet(pl, pl.sheet.right, pl.sheet.top + 10)); // half-open right edge + CHECK(popupOutsideSheet(pl, pl.sheet.x - 1, pl.sheet.y + 10)); + CHECK(popupOutsideSheet(pl, pl.sheet.right(), pl.sheet.y + 10)); // half-open right edge // On the sheet (title row, curve box, padding): inside. - CHECK(!popupOutsideSheet(pl, pl.sheet.left, pl.sheet.top)); - CHECK(!popupOutsideSheet(pl, pl.curveBox.left + 5, pl.curveBox.top + 5)); - CHECK(!popupOutsideSheet(pl, pl.sheet.right - 1, pl.sheet.bottom - 1)); + CHECK(!popupOutsideSheet(pl, pl.sheet.x, pl.sheet.y)); + CHECK(!popupOutsideSheet(pl, pl.curveBox.x + 5, pl.curveBox.y + 5)); + CHECK(!popupOutsideSheet(pl, pl.sheet.right() - 1, pl.sheet.bottom() - 1)); } int main() { diff --git a/tests/test_drag_out.cpp b/tests/test_drag_out.cpp index 8da3977..3511c44 100644 --- a/tests/test_drag_out.cpp +++ b/tests/test_drag_out.cpp @@ -9,13 +9,14 @@ // * Path-list assembly: single, multi, dedupe (cross-bank copy case), skip-missing, // skip-unresolved, empty selection, order preservation, mixed tallies. -#include "../src/drag_out.h" +#include "../src/core/ui/drag_out.h" #include #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index 2d363bf..858fcb7 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::editor_geometry — no VST3, no REAPER, no test +// Standalone tests for reasampler::instrument::ui::editor_geometry — no VST3, no REAPER, no test // framework. Same fast assert loop as the sibling pure tests (mode_switch et al.): // assert the IPlugView LICE editor's layout math + hit-testing directly. // @@ -8,11 +8,12 @@ // the button, missing on the title/canvas, missing outside the surface, and boundary // pixels; layout<->hit-test agreement (a click on the drawn button rect hits it). -#include "../src/vst/editor_geometry.h" +#include "../src/core/instrument/ui/editor_geometry.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -21,7 +22,7 @@ static int g_fail = 0; // --- contains() --------------------------------------------------------------- static void testContainsHalfOpen() { - Rect r{10, 20, 50, 40}; // [10,50) x [20,40) + Rect r = Rect::ltrb(10, 20, 50, 40); // [10,50) x [20,40) CHECK(contains(r, 10, 20)); // top-left inclusive CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside CHECK(!contains(r, 50, 30)); // right edge excluded @@ -31,9 +32,9 @@ static void testContainsHalfOpen() { } static void testContainsDegenerate() { - CHECK(!contains(Rect{10, 10, 10, 20}, 10, 15)); // zero width - CHECK(!contains(Rect{10, 10, 20, 10}, 15, 10)); // zero height - CHECK(!contains(Rect{20, 10, 10, 20}, 15, 15)); // inverted (right < left) + CHECK(!contains(Rect::ltrb(10, 10, 10, 20), 10, 15)); // zero width + CHECK(!contains(Rect::ltrb(10, 10, 20, 10), 15, 10)); // zero height + CHECK(!contains(Rect::ltrb(20, 10, 10, 20), 15, 15)); // inverted (right < left) } // --- layoutEditor: normal view ------------------------------------------------ @@ -43,20 +44,20 @@ static void testLayoutNormalView() { // rest; button sits inside the canvas, inset by the margin. const EditorLayout L = layoutEditor(400, 260); - CHECK(L.titleBar.left == 0 && L.titleBar.top == 0); - CHECK(L.titleBar.right == 400); - CHECK(L.titleBar.height() > 0 && L.titleBar.height() <= 260); + CHECK(L.titleBar.x == 0 && L.titleBar.y == 0); + CHECK(L.titleBar.right() == 400); + CHECK(L.titleBar.height > 0 && L.titleBar.height <= 260); // Canvas begins right below the title bar and reaches the bottom-right. - CHECK(L.canvas.top == L.titleBar.bottom); - CHECK(L.canvas.right == 400 && L.canvas.bottom == 260); + CHECK(L.canvas.y == L.titleBar.bottom()); + CHECK(L.canvas.right() == 400 && L.canvas.bottom() == 260); // Button is inside the canvas (does not overhang any edge). - CHECK(L.button.left >= L.canvas.left); - CHECK(L.button.top >= L.canvas.top); - CHECK(L.button.right <= L.canvas.right); - CHECK(L.button.bottom <= L.canvas.bottom); - CHECK(L.button.width() > 0 && L.button.height() > 0); + CHECK(L.button.x >= L.canvas.x); + CHECK(L.button.y >= L.canvas.y); + CHECK(L.button.right() <= L.canvas.right()); + CHECK(L.button.bottom() <= L.canvas.bottom()); + CHECK(L.button.width > 0 && L.button.height > 0); } // --- layoutEditor: tiny view (clamping) --------------------------------------- @@ -65,25 +66,25 @@ static void testLayoutTinyViewClampsButton() { // A view narrower/shorter than the button's natural size: the button must clamp to // the canvas and never produce an inverted or overhanging rect. const EditorLayout L = layoutEditor(40, 40); - CHECK(L.button.right <= L.canvas.right); - CHECK(L.button.bottom <= L.canvas.bottom); - CHECK(L.button.right >= L.button.left); // never inverted - CHECK(L.button.bottom >= L.button.top); + CHECK(L.button.right() <= L.canvas.right()); + CHECK(L.button.bottom() <= L.canvas.bottom()); + CHECK(L.button.right() >= L.button.x); // never inverted + CHECK(L.button.bottom() >= L.button.y); // Title bar clamps to the client height when the view is shorter than its height. - CHECK(L.titleBar.bottom <= 40); + CHECK(L.titleBar.bottom() <= 40); } // --- layoutEditor: zero view (all empty, no inversion) ------------------------ static void testLayoutZeroView() { const EditorLayout L = layoutEditor(0, 0); - CHECK(L.titleBar.width() <= 0 || L.titleBar.height() <= 0); - CHECK(L.canvas.width() <= 0 || L.canvas.height() <= 0); + CHECK(L.titleBar.width <= 0 || L.titleBar.height <= 0); + CHECK(L.canvas.width <= 0 || L.canvas.height <= 0); // No rect is inverted. - CHECK(L.button.right >= L.button.left); - CHECK(L.button.bottom >= L.button.top); - CHECK(L.canvas.right >= L.canvas.left); - CHECK(L.canvas.bottom >= L.canvas.top); + CHECK(L.button.right() >= L.button.x); + CHECK(L.button.bottom() >= L.button.y); + CHECK(L.canvas.right() >= L.canvas.x); + CHECK(L.canvas.bottom() >= L.canvas.y); // A click anywhere on an empty layout hits nothing. CHECK(hitTest(L, 0, 0) == HitTarget::kNone); CHECK(hitTest(L, 5, 5) == HitTarget::kNone); @@ -94,15 +95,15 @@ static void testLayoutZeroView() { static void testHitTestButton() { const EditorLayout L = layoutEditor(400, 260); // Center of the button hits it. - const int cx = (L.button.left + L.button.right) / 2; - const int cy = (L.button.top + L.button.bottom) / 2; + const int cx = (L.button.x + L.button.right()) / 2; + const int cy = (L.button.y + L.button.bottom()) / 2; CHECK(hitTest(L, cx, cy) == HitTarget::kButton); } static void testHitTestMissesNonButton() { const EditorLayout L = layoutEditor(400, 260); // Title bar is inert in the spike. - CHECK(hitTest(L, 200, L.titleBar.top + 1) == HitTarget::kNone); + CHECK(hitTest(L, 200, L.titleBar.y + 1) == HitTarget::kNone); // Empty canvas away from the button. CHECK(hitTest(L, 380, 240) == HitTarget::kNone); // Outside the surface entirely. @@ -113,9 +114,9 @@ static void testHitTestMissesNonButton() { static void testHitTestButtonBoundary() { const EditorLayout L = layoutEditor(400, 260); // Top-left corner of the button is inclusive; the right/bottom edges are excluded. - CHECK(hitTest(L, L.button.left, L.button.top) == HitTarget::kButton); - CHECK(hitTest(L, L.button.right, L.button.top) == HitTarget::kNone); - CHECK(hitTest(L, L.button.left, L.button.bottom) == HitTarget::kNone); + CHECK(hitTest(L, L.button.x, L.button.y) == HitTarget::kButton); + CHECK(hitTest(L, L.button.right(), L.button.y) == HitTarget::kNone); + CHECK(hitTest(L, L.button.x, L.button.bottom()) == HitTarget::kNone); } // --- layout<->hit-test agreement ---------------------------------------------- @@ -124,8 +125,8 @@ static void testHitTestButtonBoundary() { // load-bearing consistency invariant between what the shell draws and what it routes. static void testHitTestMatchesDrawnButton() { const EditorLayout L = layoutEditor(320, 200); - for (int y = L.button.top; y < L.button.bottom; ++y) { - for (int x = L.button.left; x < L.button.right; ++x) { + for (int y = L.button.y; y < L.button.bottom(); ++y) { + for (int x = L.button.x; x < L.button.right(); ++x) { CHECK(hitTest(L, x, y) == HitTarget::kButton); } } @@ -138,14 +139,14 @@ static void testSampleRowRectStacks() { const Rect r0 = sampleRowRect(L, 0); const Rect r1 = sampleRowRect(L, 1); // Row 0 starts at the canvas top and spans its full width. - CHECK(r0.top == L.canvas.top); - CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right); - CHECK(r0.height() == kSampleRowHeight); + CHECK(r0.y == L.canvas.y); + CHECK(r0.x == L.canvas.x && r0.right() == L.canvas.right()); + CHECK(r0.height == kSampleRowHeight); // Row 1 sits directly below row 0 (no gap, no overlap). - CHECK(r1.top == r0.bottom); - CHECK(r1.height() == kSampleRowHeight); + CHECK(r1.y == r0.bottom()); + CHECK(r1.height == kSampleRowHeight); // A negative index is an empty rect. - CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0); + CHECK(sampleRowRect(L, -1).width == 0 && sampleRowRect(L, -1).height == 0); } static void testSampleRowHitTestMapsClickToRow() { @@ -153,35 +154,35 @@ static void testSampleRowHitTestMapsClickToRow() { const int rows = 5; // A click in the vertical middle of row 2 resolves to index 2. const Rect r2 = sampleRowRect(L, 2); - const int midY = (r2.top + r2.bottom) / 2; + const int midY = (r2.y + r2.bottom()) / 2; CHECK(sampleRowHitTest(L, rows, 200, midY) == 2); // Row 0's top-left corner hits row 0. const Rect r0 = sampleRowRect(L, 0); - CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0); + CHECK(sampleRowHitTest(L, rows, r0.x, r0.y) == 0); } static void testSampleRowHitTestMisses() { const EditorLayout L = layoutEditor(400, 260); const int rows = 3; // Above the first row (in the title bar) -> no row. - CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1); + CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.y) == -1); // Below the last row -> no row. const Rect last = sampleRowRect(L, rows - 1); - CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1); + CHECK(sampleRowHitTest(L, rows, 200, last.bottom() + 1) == -1); // Left of the canvas -> no row. - CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1); + CHECK(sampleRowHitTest(L, rows, L.canvas.x - 1, last.y) == -1); // Zero rows -> always -1. - CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1); - // At or below canvas.bottom -> always -1, even if rowCount would cover that y. + CHECK(sampleRowHitTest(L, 0, 200, L.canvas.y + 1) == -1); + // At or below canvas.bottom() -> always -1, even if rowCount would cover that y. // This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas, - // so without this clip a row that extends past canvas.bottom would hit-test but + // so without this clip a row that extends past canvas.bottom() would hit-test but // never be drawn (or vice versa). - CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom) == -1); + CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom()) == -1); // Use a large rowCount so index arithmetic would return a valid row without the - // canvas.bottom guard — proving the guard fires independently of rowCount. + // canvas.bottom() guard — proving the guard fires independently of rowCount. const int bigRows = 1000; - CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom) == -1); - CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom + 5) == -1); + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom()) == -1); + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom() + 5) == -1); } // The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to @@ -191,10 +192,10 @@ static void testSampleRowHitTestMatchesDrawnRows() { const int rows = 4; for (int i = 0; i < rows; ++i) { const Rect r = sampleRowRect(L, i); - if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets - const int y = (r.top + r.bottom) / 2; - if (y >= L.canvas.bottom) continue; - CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i); + if (r.y >= L.canvas.bottom()) break; // clipped rows aren't clickable targets + const int y = (r.y + r.bottom()) / 2; + if (y >= L.canvas.bottom()) continue; + CHECK(sampleRowHitTest(L, rows, r.x + 1, y) == i); } } @@ -204,30 +205,30 @@ static void testKeymapLayoutSplitsCanvas() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); // The left sample list and right zone panel partition the canvas with no overlap and // no gap: the list's right edge is the panel's left edge. - CHECK(L.sampleList.left == L.base.canvas.left); - CHECK(L.sampleList.right == L.zonePanel.left); - CHECK(L.zonePanel.right == L.base.canvas.right); - CHECK(L.sampleList.top == L.base.canvas.top); - CHECK(L.zonePanel.top == L.base.canvas.top); - CHECK(L.sampleList.bottom == L.base.canvas.bottom); - CHECK(L.zonePanel.bottom == L.base.canvas.bottom); - CHECK(L.sampleList.width() > 0 && L.zonePanel.width() > 0); + CHECK(L.sampleList.x == L.base.canvas.x); + CHECK(L.sampleList.right() == L.zonePanel.x); + CHECK(L.zonePanel.right() == L.base.canvas.right()); + CHECK(L.sampleList.y == L.base.canvas.y); + CHECK(L.zonePanel.y == L.base.canvas.y); + CHECK(L.sampleList.bottom() == L.base.canvas.bottom()); + CHECK(L.zonePanel.bottom() == L.base.canvas.bottom()); + CHECK(L.sampleList.width > 0 && L.zonePanel.width > 0); // Add-Zone button caps the panel; zone rows stack below it. - CHECK(L.addZoneButton.top == L.zonePanel.top); - CHECK(L.addZoneButton.left == L.zonePanel.left && L.addZoneButton.right == L.zonePanel.right); - CHECK(L.zoneRowArea.top == L.addZoneButton.bottom); - CHECK(L.zoneRowArea.bottom == L.zonePanel.bottom); + CHECK(L.addZoneButton.y == L.zonePanel.y); + CHECK(L.addZoneButton.x == L.zonePanel.x && L.addZoneButton.right() == L.zonePanel.right()); + CHECK(L.zoneRowArea.y == L.addZoneButton.bottom()); + CHECK(L.zoneRowArea.bottom() == L.zonePanel.bottom()); } static void checkNoInversion(const KeymapEditorLayout& L) { - CHECK(L.sampleList.right >= L.sampleList.left); - CHECK(L.zonePanel.right >= L.zonePanel.left); - CHECK(L.addZoneButton.right >= L.addZoneButton.left); - CHECK(L.addZoneButton.bottom >= L.addZoneButton.top); - CHECK(L.zoneRowArea.right >= L.zoneRowArea.left); - CHECK(L.zoneRowArea.bottom >= L.zoneRowArea.top); + CHECK(L.sampleList.right() >= L.sampleList.x); + CHECK(L.zonePanel.right() >= L.zonePanel.x); + CHECK(L.addZoneButton.right() >= L.addZoneButton.x); + CHECK(L.addZoneButton.bottom() >= L.addZoneButton.y); + CHECK(L.zoneRowArea.right() >= L.zoneRowArea.x); + CHECK(L.zoneRowArea.bottom() >= L.zoneRowArea.y); // Regions stay within the client area. - CHECK(L.zonePanel.right <= L.base.canvas.right); + CHECK(L.zonePanel.right() <= L.base.canvas.right()); } static void testKeymapLayoutTinyAndZeroNoInversion() { @@ -243,36 +244,36 @@ static void testKeymapSampleRowInLeftColumn() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect r0 = keymapSampleRowRect(L, 0); // Rows live in the LEFT column (not the full canvas width). - CHECK(r0.left == L.sampleList.left && r0.right == L.sampleList.right); - CHECK(r0.right < L.base.canvas.right); // strictly left of the zone panel - CHECK(r0.top == L.sampleList.top && r0.height() == kSampleRowHeight); + CHECK(r0.x == L.sampleList.x && r0.right() == L.sampleList.right()); + CHECK(r0.right() < L.base.canvas.right()); // strictly left of the zone panel + CHECK(r0.y == L.sampleList.y && r0.height == kSampleRowHeight); // Hit-test maps a left-column click to the row and rejects a click in the zone panel. - const int midY = (r0.top + r0.bottom) / 2; - CHECK(keymapSampleRowHitTest(L, 3, r0.left + 2, midY) == 0); - CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.left + 2, midY) == -1); + const int midY = (r0.y + r0.bottom()) / 2; + CHECK(keymapSampleRowHitTest(L, 3, r0.x + 2, midY) == 0); + CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.x + 2, midY) == -1); } static void testAddZoneHitTest() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); - const int cx = (L.addZoneButton.left + L.addZoneButton.right) / 2; - const int cy = (L.addZoneButton.top + L.addZoneButton.bottom) / 2; + const int cx = (L.addZoneButton.x + L.addZoneButton.right()) / 2; + const int cy = (L.addZoneButton.y + L.addZoneButton.bottom()) / 2; CHECK(addZoneHitTest(L, cx, cy)); // A click in the zone-row area below the button is NOT the Add button. - CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.top + 2)); + CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.y + 2)); // A click in the left list is NOT the Add button. - CHECK(!addZoneHitTest(L, L.sampleList.left + 2, L.sampleList.top + 2)); + CHECK(!addZoneHitTest(L, L.sampleList.x + 2, L.sampleList.y + 2)); } static void testZoneRowStacksAndSelects() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect z0 = zoneRowRect(L, 0); const Rect z1 = zoneRowRect(L, 1); - CHECK(z0.top == L.zoneRowArea.top && z0.height() == kZoneRowHeight); - CHECK(z1.top == z0.bottom); // stacked, no gap - CHECK(z0.left == L.zoneRowArea.left && z0.right == L.zoneRowArea.right); + CHECK(z0.y == L.zoneRowArea.y && z0.height == kZoneRowHeight); + CHECK(z1.y == z0.bottom()); // stacked, no gap + CHECK(z0.x == L.zoneRowArea.x && z0.right() == L.zoneRowArea.right()); // A click on the LABEL area (left part of a zone row) selects the zone with no field. - const int labelX = z0.left + 2; // far left = label, not a control - const int midY = (z0.top + z0.bottom) / 2; + const int labelX = z0.x + 2; // far left = label, not a control + const int midY = (z0.y + z0.bottom()) / 2; const ZoneHit h = zoneHitTest(L, 2, labelX, midY); CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone); } @@ -280,10 +281,10 @@ static void testZoneRowStacksAndSelects() { static void testZoneRowControlsMapToFields() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect row = zoneRowRect(L, 0); - const int midY = (row.top + row.bottom) / 2; + const int midY = (row.y + row.bottom()) / 2; // The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right: // low-, low+, high-, high+, root-, root+, delete. - const int block = row.right - 7 * kZoneCtrlWidth; + const int block = row.right() - 7 * kZoneCtrlWidth; const ZoneField expected[7] = { ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, @@ -300,14 +301,14 @@ static void testZoneRowControlsMapToFields() { static void testZoneHitTestMisses() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect row = zoneRowRect(L, 0); - const int midY = (row.top + row.bottom) / 2; + const int midY = (row.y + row.bottom()) / 2; // Zero zones -> always miss. - CHECK(zoneHitTest(L, 0, row.left + 2, midY).zoneIndex == -1); + CHECK(zoneHitTest(L, 0, row.x + 2, midY).zoneIndex == -1); // Below the last zone row -> miss. const Rect last = zoneRowRect(L, 2); - CHECK(zoneHitTest(L, 3, row.left + 2, last.bottom + 1).zoneIndex == -1); + CHECK(zoneHitTest(L, 3, row.x + 2, last.bottom() + 1).zoneIndex == -1); // Left of the zone panel (in the sample list) -> miss. - CHECK(zoneHitTest(L, 3, L.sampleList.left + 2, midY).zoneIndex == -1); + CHECK(zoneHitTest(L, 3, L.sampleList.x + 2, midY).zoneIndex == -1); } int main() { diff --git a/tests/test_embed_strip.cpp b/tests/test_embed_strip.cpp index 4ce3c22..9d3ea93 100644 --- a/tests/test_embed_strip.cpp +++ b/tests/test_embed_strip.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::embed_strip — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::embed_strip — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the // embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly. // @@ -9,11 +9,12 @@ // on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list; // levelFillRect clamping 0..1 and its endpoints. -#include "../src/vst/embed_strip.h" +#include "../src/core/instrument/ui/embed_strip.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -24,34 +25,34 @@ static int g_fail = 0; static void testLayoutNormalArea() { // A comfortable inline strip: keymap band on top, thin level band pinned to the bottom. const EmbedLayout L = layoutEmbed(300, 40); - CHECK(L.keymap.left == 0 && L.keymap.top == 0 && L.keymap.right == 300); - CHECK(L.levelBand.left == 0 && L.levelBand.right == 300); + CHECK(L.keymap.x == 0 && L.keymap.y == 0 && L.keymap.right() == 300); + CHECK(L.levelBand.x == 0 && L.levelBand.right() == 300); // Level band is the fixed height at the very bottom; keymap fills the rest, contiguous. - CHECK(L.levelBand.height() == kEmbedLevelBandHeight); - CHECK(L.levelBand.bottom == 40); - CHECK(L.keymap.bottom == L.levelBand.top); - CHECK(L.keymap.height() == 40 - kEmbedLevelBandHeight); + CHECK(L.levelBand.height == kEmbedLevelBandHeight); + CHECK(L.levelBand.bottom() == 40); + CHECK(L.keymap.bottom() == L.levelBand.y); + CHECK(L.keymap.height == 40 - kEmbedLevelBandHeight); } static void testLayoutTinyAreaKeepsKeymap() { // A very short area: the level band must yield so the keymap keeps its minimum, and no // rect inverts. const EmbedLayout L = layoutEmbed(300, 8); - CHECK(L.keymap.height() >= 0); - CHECK(L.levelBand.height() >= 0); - CHECK(L.keymap.bottom == L.levelBand.top); - CHECK(L.levelBand.bottom == 8); + CHECK(L.keymap.height >= 0); + CHECK(L.levelBand.height >= 0); + CHECK(L.keymap.bottom() == L.levelBand.y); + CHECK(L.levelBand.bottom() == 8); // The keymap is not starved below its floor when the area allows it. - CHECK(L.keymap.height() >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); + CHECK(L.keymap.height >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); } static void testLayoutZeroArea() { const EmbedLayout L = layoutEmbed(0, 0); - CHECK(L.keymap.width() <= 0 && L.keymap.height() <= 0); - CHECK(L.levelBand.width() <= 0 && L.levelBand.height() <= 0); + CHECK(L.keymap.width <= 0 && L.keymap.height <= 0); + CHECK(L.levelBand.width <= 0 && L.levelBand.height <= 0); // Negative dimensions clamp to a zero-area, non-inverted rect. const EmbedLayout N = layoutEmbed(-50, -50); - CHECK(N.keymap.right >= N.keymap.left && N.keymap.bottom >= N.keymap.top); + CHECK(N.keymap.right() >= N.keymap.x && N.keymap.bottom() >= N.keymap.y); } // --- zoneSegmentRect ---------------------------------------------------------- @@ -60,9 +61,9 @@ static void testZoneSegmentFullSpan() { // A zone covering the whole keyboard spans the entire keymap band width. const EmbedLayout L = layoutEmbed(256, 40); const Rect r = zoneSegmentRect(L, 0, 127); - CHECK(r.left == L.keymap.left); - CHECK(r.right == L.keymap.right); - CHECK(r.top == L.keymap.top && r.bottom == L.keymap.bottom); + CHECK(r.x == L.keymap.x); + CHECK(r.right() == L.keymap.right()); + CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom()); } static void testAdjacentZonesTileSeamlessly() { @@ -71,10 +72,10 @@ static void testAdjacentZonesTileSeamlessly() { const EmbedLayout L = layoutEmbed(256, 40); const Rect lo = zoneSegmentRect(L, 0, 59); const Rect hi = zoneSegmentRect(L, 60, 127); - CHECK(lo.left == L.keymap.left); - CHECK(hi.right == L.keymap.right); - CHECK(lo.right == hi.left); // seamless tile — the load-bearing assertion - CHECK(lo.right == L.keymap.left + 60 * 2); // 60 keys * 2px + CHECK(lo.x == L.keymap.x); + CHECK(hi.right() == L.keymap.right()); + CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion + CHECK(lo.right() == L.keymap.x + 60 * 2); // 60 keys * 2px } static void testZoneSegmentClampsBadNotes() { @@ -82,9 +83,9 @@ static void testZoneSegmentClampsBadNotes() { // Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a // zero-or-positive-width rect, never inverts. const Rect over = zoneSegmentRect(L, -10, 200); - CHECK(over.left == L.keymap.left && over.right == L.keymap.right); + CHECK(over.x == L.keymap.x && over.right() == L.keymap.right()); const Rect inv = zoneSegmentRect(L, 100, 20); - CHECK(inv.right >= inv.left); + CHECK(inv.right() >= inv.x); } // --- zoneAtPoint -------------------------------------------------------------- @@ -95,31 +96,31 @@ static void testZoneAtPointHits() { // A point inside the low zone's segment resolves to zone 0; inside the high zone, 1. const Rect lo = zoneSegmentRect(L, 0, 59); const Rect hi = zoneSegmentRect(L, 60, 127); - const int yMid = (L.keymap.top + L.keymap.bottom) / 2; - CHECK(zoneAtPoint(L, zones, 2, lo.left + 1, yMid) == 0); - CHECK(zoneAtPoint(L, zones, 2, hi.right - 1, yMid) == 1); + const int yMid = (L.keymap.y + L.keymap.bottom()) / 2; + CHECK(zoneAtPoint(L, zones, 2, lo.x + 1, yMid) == 0); + CHECK(zoneAtPoint(L, zones, 2, hi.right() - 1, yMid) == 1); } static void testZoneAtPointFirstMatchOnOverlap() { const EmbedLayout L = layoutEmbed(256, 40); // Two overlapping zones; the FIRST in order must win the contested keys. const EmbedZone zones[2] = {{0, 127}, {40, 80}}; - const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const int yMid = (L.keymap.y + L.keymap.bottom()) / 2; const Rect contested = zoneSegmentRect(L, 40, 80); - CHECK(zoneAtPoint(L, zones, 2, contested.left + 1, yMid) == 0); // zone 0 wins + CHECK(zoneAtPoint(L, zones, 2, contested.x + 1, yMid) == 0); // zone 0 wins } static void testZoneAtPointMisses() { const EmbedLayout L = layoutEmbed(256, 40); const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered - const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const int yMid = (L.keymap.y + L.keymap.bottom()) / 2; // A key left of the zone is uncovered -> -1. - CHECK(zoneAtPoint(L, zones, 1, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, zones, 1, L.keymap.x + 1, yMid) == -1); // A point in the level band (below the keymap) is off the keymap -> -1. - CHECK(zoneAtPoint(L, zones, 1, L.levelBand.left + 4, L.levelBand.top) == -1); + CHECK(zoneAtPoint(L, zones, 1, L.levelBand.x + 4, L.levelBand.y) == -1); // Empty / null list -> -1. - CHECK(zoneAtPoint(L, zones, 0, L.keymap.left + 1, yMid) == -1); - CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, zones, 0, L.keymap.x + 1, yMid) == -1); + CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.x + 1, yMid) == -1); } // --- levelFillRect ------------------------------------------------------------ @@ -127,16 +128,16 @@ static void testZoneAtPointMisses() { static void testLevelFillClamps() { const EmbedLayout L = layoutEmbed(200, 40); // Zero / negative -> empty. - CHECK(levelFillRect(L, 0.0).width() <= 0); - CHECK(levelFillRect(L, -1.0).width() <= 0); + CHECK(levelFillRect(L, 0.0).width <= 0); + CHECK(levelFillRect(L, -1.0).width <= 0); // Full / over-full -> the whole band width. - CHECK(levelFillRect(L, 1.0).width() == L.levelBand.width()); - CHECK(levelFillRect(L, 5.0).width() == L.levelBand.width()); + CHECK(levelFillRect(L, 1.0).width == L.levelBand.width); + CHECK(levelFillRect(L, 5.0).width == L.levelBand.width); // Half -> ~half the band, pinned to the band's left and vertical extent. const Rect half = levelFillRect(L, 0.5); - CHECK(half.left == L.levelBand.left); - CHECK(half.top == L.levelBand.top && half.bottom == L.levelBand.bottom); - CHECK(half.width() == L.levelBand.width() / 2); + CHECK(half.x == L.levelBand.x); + CHECK(half.y == L.levelBand.y && half.bottom() == L.levelBand.bottom()); + CHECK(half.width == L.levelBand.width / 2); } int main() { diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp index 6b40cac..37f92a3 100644 --- a/tests/test_envelope_edit.cpp +++ b/tests/test_envelope_edit.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::envelope_edit — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::envelope_edit — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3 draggable-node INVERSE // map: node hit-test + pixel-delta -> clamped/monotonic param set, HARD at the clamp + monotonic // boundaries (the load-bearing "a drag can never produce a param a slider couldn't" invariant). @@ -14,14 +14,15 @@ // pixel delta; zero-fade-out node grabbable at the right edge and draggable inward — FA2); // degenerate area/duration + non-draggable node + cross-mode node -> no motion. -#include "../src/vst/envelope_edit.h" +#include "../src/core/instrument/ui/envelope_edit.h" #include #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -41,7 +42,7 @@ static bool findNode(const std::vector& poly, EnvNode node, EnvVertex // scale (FA2 param-domain schematic — sample-length-free): (850-1-32)px over the 8.0s schematic // domain => 102.125 px/s, each segment prefixed by the 8px separation base; the gateEnv() nodes // draw at A x@28, H x@47, D x@85, RS x@235, RE x@284. -static Rect wideArea() { return Rect{20, 10, 1020, 110}; } +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } static constexpr double kTotal = 2.0; static const double kGateSecPerPx = 1.0 / gatePxPerSecond(wideArea()); @@ -71,10 +72,10 @@ static void testHitGrabsDrawnHandle() { const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); // AttackEnd draws at x = left+28 (8px base + 0.2s * 102.125 px/s), y = top (level 1). - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 28, a.top); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 28, a.y); CHECK(h.hit && h.node == EnvNode::AttackEnd); // The sustain node (DecayEnd) at left+85, level 0.5 -> ~top+50. - NodeHit s = nodeAtPoint(e, a, kTotal, a.left + 85, a.top + 50); + NodeHit s = nodeAtPoint(e, a, kTotal, a.x + 85, a.y + 50); CHECK(s.hit && s.node == EnvNode::DecayEnd); } @@ -82,7 +83,7 @@ static void testHitMissesOffEveryNode() { const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); // A point far from any drawn handle (right of the release ramp, well away from a node). - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 700, a.top + 5); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 700, a.y + 5); CHECK(!h.hit); } @@ -90,11 +91,11 @@ static void testHitSkipsNonDraggableAnchors() { const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); // Origin draws at (left, bottom-1). Even a pixel-perfect grab there is NOT a draggable node. - NodeHit o = nodeAtPoint(e, a, kTotal, a.left, a.bottom - 1); + NodeHit o = nodeAtPoint(e, a, kTotal, a.x, a.bottom() - 1); CHECK(!o.hit); // ReleaseStart draws at (left+235, sustain level ~top+50) — the fixed plateau end. It is // drawing-only -> not grabbable; no other node is within the radius, so this grab misses. - NodeHit rs = nodeAtPoint(e, a, kTotal, a.left + 235, a.top + 50); + NodeHit rs = nodeAtPoint(e, a, kTotal, a.x + 235, a.y + 50); CHECK(!rs.hit); } @@ -106,7 +107,7 @@ static void testHitNearestNodeWinsOverDrawOrder() { AmpEnvelope e = gateEnv(); e.holdSeconds = 0.01; const Rect a = wideArea(); - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 33, a.top); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 33, a.y); CHECK(h.hit && h.node == EnvNode::HoldEnd); } @@ -199,11 +200,11 @@ static void testGateTimeOnlyNodeIgnoresY() { static void testGateReleaseEndGrabAndDrag() { // The FA2 fix: ReleaseEnd is a drawn, IN-BOUNDS, grabbable handle (pre-FA2 it mapped past - // area.right and could never be grabbed). gateEnv() draws it at x@284, level 0 (bottom row). + // area.right() and could never be grabbed). gateEnv() draws it at x@284, level 0 (bottom row). const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); EnvClampBounds b; - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 284, a.bottom - 1); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 284, a.bottom() - 1); CHECK(h.hit && h.node == EnvNode::ReleaseEnd); // Dragging it RIGHT lengthens the release at the gate timed scale; only release changes. AmpEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseEnd, a, kTotal, b, 85, 0); @@ -289,14 +290,14 @@ static void testTriggerZeroFadeOutGrabbableAtRightEdge() { e.fadeOutFraction = 0.0; const Rect a = wideArea(); EnvClampBounds b; - NodeHit h = nodeAtPoint(e, a, kTotal, a.right - 1, a.top); + NodeHit h = nodeAtPoint(e, a, kTotal, a.right() - 1, a.y); CHECK(h.hit && h.node == EnvNode::FadeOutStart); // -100px = -0.2s on the 2.0s played span, applied OPPOSITE -> fadeOut 0.0 -> 0.1. AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, a, kTotal, b, -100, 0); CHECK(near(out.fadeOutFraction, 0.1)); CHECK(near(out.lengthFraction, e.lengthFraction)); // length untouched // LengthEnd sits at the same x but level 0 (bottom row) — grabbable at ITS drawn point. - NodeHit le = nodeAtPoint(e, a, kTotal, a.right - 1, a.bottom - 1); + NodeHit le = nodeAtPoint(e, a, kTotal, a.right() - 1, a.bottom() - 1); CHECK(le.hit && le.node == EnvNode::LengthEnd); } @@ -327,7 +328,7 @@ static void testNonDraggableNodeNoMotion() { static void testDegenerateAreaNoMotion() { const AmpEnvelope e = gateEnv(); EnvClampBounds b; - const Rect zeroW = Rect{0, 0, 0, 100}; + const Rect zeroW = Rect::ltrb(0, 0, 0, 100); AmpEnvelope o1 = resolveNodeDrag(e, EnvNode::AttackEnd, zeroW, kTotal, b, 500, 0); CHECK(near(o1.attackSeconds, e.attackSeconds)); AmpEnvelope o2 = resolveNodeDrag(e, EnvNode::AttackEnd, wideArea(), 0.0, b, 500, 0); // no time @@ -347,7 +348,7 @@ static void testCrossModeNodeNoMotion() { out = resolveNodeDrag(g, EnvNode::FadeInEnd, wideArea(), kTotal, b, 50, 0); CHECK(near(out.fadeInFraction, g.fadeInFraction)); // And the zero-height baseline's ReleaseEnd is not even reported grabbable in Trigger mode. - const Rect flat = Rect{0, 0, 100, 0}; + const Rect flat = Rect::ltrb(0, 0, 100, 0); const NodeHit h = nodeAtPoint(t, flat, kTotal, 99, 0); CHECK(!h.hit); } diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp index bd09245..6839bec 100644 --- a/tests/test_envelope_overlay.cpp +++ b/tests/test_envelope_overlay.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::envelope_overlay — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::envelope_overlay — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3/FA2 amp-envelope -> // polyline FORWARD map: the Gate BOUNDED-SCHEMATIC AHDSR shape (attack ramp / hold plateau / // decay-to-sustain / fixed-width sustain plateau / in-bounds release) and the Trigger @@ -14,12 +14,13 @@ // the played span, overlap clamp, full-length/zero-fade-out nodes in-bounds at right-1); // degenerate flat baseline. -#include "../src/vst/envelope_overlay.h" +#include "../src/core/instrument/ui/envelope_overlay.h" #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -27,7 +28,7 @@ static int g_fail = 0; // A comfortable overlay area: 1000px wide, 100px tall, offset so left/top != 0 (catches origin // bugs). Under levelToY the level span is height-1 = 99 rows. -static Rect wideArea() { return Rect{20, 10, 1020, 110}; } // width 1000, height 100 +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100 // Find the first vertex with a given node in a polyline; asserts presence via the returned bool. static bool findNode(const std::vector& poly, EnvNode node, EnvVertex& out) { @@ -41,32 +42,32 @@ static bool findNode(const std::vector& poly, EnvNode node, EnvVertex static void testTimeToXEndpoints() { const Rect a = wideArea(); - CHECK(timeToX(a, 2.0, 0.0) == a.left); // t=0 -> left - CHECK(timeToX(a, 2.0, 2.0) == a.right - 1); // t=total -> last in-bounds column - CHECK(timeToX(a, 2.0, 1.0) == a.left + 500); // midpoint + CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left + CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column + CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint } static void testTimeToXNegativePinsLeft() { const Rect a = wideArea(); - CHECK(timeToX(a, 2.0, -0.5) == a.left); // t<0 pins left + CHECK(timeToX(a, 2.0, -0.5) == a.x); // t<0 pins left } static void testTimeToXPastEndClamps() { // FA2 bounds invariant: t past total pins to the last in-bounds column, never past right. const Rect a = wideArea(); - CHECK(timeToX(a, 2.0, 3.0) == a.right - 1); - CHECK(timeToX(a, 2.0, 1000.0) == a.right - 1); + CHECK(timeToX(a, 2.0, 3.0) == a.right() - 1); + CHECK(timeToX(a, 2.0, 1000.0) == a.right() - 1); // A HUGE t must clamp in double space, not overflow the integer cast (32-bit long on // Windows would wrap to LONG_MIN and pin to the WRONG edge). - CHECK(timeToX(a, 2.0, 1e15) == a.right - 1); + CHECK(timeToX(a, 2.0, 1e15) == a.right() - 1); } static void testGateTimedWidth() { // 15% of the 1000px canvas is reserved for the sustain plateau -> 850px timed region. CHECK(gateTimedWidth(wideArea()) == 850); // Zero-width area -> 0; a tiny area still yields >= 1 so the px<->s scale never degenerates. - CHECK(gateTimedWidth(Rect{5, 5, 5, 45}) == 0); - CHECK(gateTimedWidth(Rect{0, 0, 1, 10}) == 1); + CHECK(gateTimedWidth(Rect::ltrb(5, 5, 5, 45)) == 0); + CHECK(gateTimedWidth(Rect::ltrb(0, 0, 1, 10)) == 1); } static void testGatePxPerSecond() { @@ -74,30 +75,30 @@ static void testGatePxPerSecond() { // 1000px canvas: (850 - 1 - 32) / 8.0s = 817/8 px/s. Independent of any sample duration. const double expected = 817.0 / (4.0 * kGateStageMaxSeconds); CHECK(gatePxPerSecond(wideArea()) == expected); - CHECK(gatePxPerSecond(Rect{5, 5, 5, 45}) == 0.0); // zero-width area -> 0 - CHECK(gatePxPerSecond(Rect{0, 0, 10, 10}) > 0.0); // tiny area: usable floors at 1px, > 0 + CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 + CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0 } static void testTimeToXDegenerate() { const Rect a = wideArea(); - CHECK(timeToX(a, 0.0, 1.0) == a.left); // no duration -> left - const Rect z = Rect{5, 5, 5, 45}; // zero width - CHECK(timeToX(z, 2.0, 1.0) == z.left); + CHECK(timeToX(a, 0.0, 1.0) == a.x); // no duration -> left + const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width + CHECK(timeToX(z, 2.0, 1.0) == z.x); } static void testLevelToYEndpoints() { const Rect a = wideArea(); - CHECK(levelToY(a, 1.0) == a.top); // level 1 -> top row - CHECK(levelToY(a, 0.0) == a.bottom - 1); // level 0 -> bottom row - CHECK(levelToY(a, 0.5) == a.top + 50); // mid: round((1-0.5)*99)=round(49.5)=50 + CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row + CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row + CHECK(levelToY(a, 0.5) == a.y + 50); // mid: round((1-0.5)*99)=round(49.5)=50 } static void testLevelToYClamps() { const Rect a = wideArea(); - CHECK(levelToY(a, 2.0) == a.top); // >1 clamps to top - CHECK(levelToY(a, -1.0) == a.bottom - 1); // <0 clamps to bottom - const Rect z = Rect{5, 5, 45, 5}; // zero height - CHECK(levelToY(z, 0.5) == z.top); + CHECK(levelToY(a, 2.0) == a.y); // >1 clamps to top + CHECK(levelToY(a, -1.0) == a.bottom() - 1); // <0 clamps to bottom + const Rect z = Rect::ltrb(5, 5, 45, 5); // zero height + CHECK(levelToY(z, 0.5) == z.y); } // --- Gate polyline ------------------------------------------------------------ @@ -149,11 +150,11 @@ static void testGateSchematicPlacement() { const std::vector poly = buildEnvelopePolyline(env, a, 2.0); EnvVertex v; - CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.left + 28); - CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.left + 47); - CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.left + 85); - CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.left + 235); - CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.left + 284); + CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 28); + CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 47); + CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 85); + CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 235); + CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 284); } static void testGateLayoutIndependentOfSampleDuration() { @@ -195,7 +196,7 @@ static void testGateSustainPlateauFixedWidth() { env.sustainLevel = 0.6; env.releaseSeconds = 0.3; const Rect a = wideArea(); - const int plateauPx = a.width() - gateTimedWidth(a); // 150 + const int plateauPx = a.width - gateTimedWidth(a); // 150 const std::vector poly = buildEnvelopePolyline(env, a, 2.0); EnvVertex decay, plateauEnd; @@ -207,7 +208,7 @@ static void testGateSustainPlateauFixedWidth() { static void testGateReleaseVisibleInBounds() { // The FA2 fix: Release is a VISIBLE, in-bounds segment — ReleaseEnd sits strictly right of - // the plateau end and strictly inside the canvas (pre-FA2 it mapped past area.right and the + // the plateau end and strictly inside the canvas (pre-FA2 it mapped past area.right() and the // shell clipped its handle away). AmpEnvelope env; env.mode = EnvMode::Gate; @@ -223,7 +224,7 @@ static void testGateReleaseVisibleInBounds() { CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd)); CHECK(findNode(poly, EnvNode::ReleaseEnd, rel)); CHECK(rel.x > plateauEnd.x); // a visible ramp, not a collapsed point - CHECK(rel.x < a.right); // strictly in-bounds + CHECK(rel.x < a.right()); // strictly in-bounds CHECK(rel.level == 0.0); } @@ -231,7 +232,7 @@ static void testGateOverrunCompressesFromRight() { // Stages BEYOND the schematic domain (4.0s each > kGateStageMaxSeconds): the layout // compresses from the right preserving the minimum gaps — ReleaseEnd pins to the last // in-bounds column, but the trailing nodes stay strictly increasing and individually - // separated (>= kGateNodeSepPx), NOT piled on one pixel. NOTHING maps past area.right. + // separated (>= kGateNodeSepPx), NOT piled on one pixel. NOTHING maps past area.right(). AmpEnvelope env; env.mode = EnvMode::Gate; env.attackSeconds = 4.0; @@ -246,12 +247,12 @@ static void testGateOverrunCompressesFromRight() { EnvVertex plateauEnd, rel; CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd)); CHECK(findNode(poly, EnvNode::ReleaseEnd, rel)); - CHECK(rel.x == a.right - 1); // pinned to the last in-bounds column + CHECK(rel.x == a.right() - 1); // pinned to the last in-bounds column CHECK(plateauEnd.level == 0.7); // still at sustain for (size_t i = 1; i < poly.size(); ++i) { CHECK(poly[i].x > poly[i - 1].x); // strictly monotonic CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1); // min gaps survive compression - CHECK(poly[i].x >= a.left && poly[i].x < a.right); // in-bounds + CHECK(poly[i].x >= a.x && poly[i].x < a.right()); // in-bounds } } @@ -279,8 +280,8 @@ static void testGateAllVerticesInBounds() { for (const AmpEnvelope& env : {base, big, zero, trig, huge}) { for (const EnvVertex& v : buildEnvelopePolyline(env, a, 2.0)) { - CHECK(v.x >= a.left && v.x < a.right); - CHECK(v.y >= a.top && v.y < a.bottom); + CHECK(v.x >= a.x && v.x < a.right()); + CHECK(v.y >= a.y && v.y < a.bottom()); } } } @@ -305,9 +306,9 @@ static void testTriggerShape() { CHECK(poly[3].node == EnvNode::LengthEnd); EnvVertex v; - CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.left + 100 && v.level == 1.0); - CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.left + 350 && v.level == 1.0); - CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.left + 500 && v.level == 0.0); + CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.x + 100 && v.level == 1.0); + CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.x + 350 && v.level == 1.0); + CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.x + 500 && v.level == 0.0); } static void testTriggerFadeOverlapClamp() { @@ -324,7 +325,7 @@ static void testTriggerFadeOverlapClamp() { CHECK(findNode(poly, EnvNode::FadeInEnd, fin)); CHECK(findNode(poly, EnvNode::FadeOutStart, fout)); CHECK(fin.x == fout.x); // fades meet exactly, never cross - CHECK(fin.x == a.left + 800); + CHECK(fin.x == a.x + 800); } static void testTriggerFullLengthZeroFadeOutInBounds() { @@ -342,8 +343,8 @@ static void testTriggerFullLengthZeroFadeOutInBounds() { EnvVertex fout, lend; CHECK(findNode(poly, EnvNode::FadeOutStart, fout)); CHECK(findNode(poly, EnvNode::LengthEnd, lend)); - CHECK(fout.x == a.right - 1); // present + in-bounds at zero fade-out - CHECK(lend.x == a.right - 1); + CHECK(fout.x == a.right() - 1); // present + in-bounds at zero fade-out + CHECK(lend.x == a.right() - 1); CHECK(fout.level == 1.0 && lend.level == 0.0); } @@ -351,7 +352,7 @@ static void testTriggerFullLengthZeroFadeOutInBounds() { static void testDegenerateFlatBaseline() { AmpEnvelope env; // any params - const Rect zeroW = Rect{0, 0, 0, 100}; + const Rect zeroW = Rect::ltrb(0, 0, 0, 100); const std::vector p1 = buildEnvelopePolyline(env, zeroW, 2.0); CHECK(p1.size() == 2); // always a drawable line CHECK(p1.front().level == 0.0 && p1.back().level == 0.0); @@ -360,7 +361,7 @@ static void testDegenerateFlatBaseline() { const std::vector p2 = buildEnvelopePolyline(env, ok, 0.0); // no duration CHECK(p2.size() == 2); CHECK(p2.front().level == 0.0 && p2.back().level == 0.0); - CHECK(p2.front().x == ok.left && p2.back().x == ok.right - 1); // spans the area, in-bounds + CHECK(p2.front().x == ok.x && p2.back().x == ok.right() - 1); // spans the area, in-bounds } int main() { diff --git a/tests/test_file_bytes.cpp b/tests/test_file_bytes.cpp index d0bc358..ba6789a 100644 --- a/tests/test_file_bytes.cpp +++ b/tests/test_file_bytes.cpp @@ -11,6 +11,7 @@ #include using namespace reasampler; +using namespace reasampler::util; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_footer_bar.cpp b/tests/test_footer_bar.cpp index ba8cb4d..73a2462 100644 --- a/tests/test_footer_bar.cpp +++ b/tests/test_footer_bar.cpp @@ -13,11 +13,12 @@ // * Hit-test: Toggle / Tail returned for in-bounds points, None outside AND on the count // label (a passive readout, never a control); half-open bounds; suppressed box claims none. -#include "../src/footer_bar.h" +#include "../src/core/ui/footer_bar.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_guid_diff.cpp b/tests/test_guid_diff.cpp index 08febad..b888ab2 100644 --- a/tests/test_guid_diff.cpp +++ b/tests/test_guid_diff.cpp @@ -10,7 +10,7 @@ // 5. reset() (project switch) re-arms the first-poll guard: the next observe() // re-baselines and reports nothing new — never diffs across projects. -#include "../src/guid_diff.h" +#include "../src/core/view/guid_diff.h" #include #include @@ -18,6 +18,7 @@ #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_insert_plan.cpp b/tests/test_insert_plan.cpp index 1339ddb..c118288 100644 --- a/tests/test_insert_plan.cpp +++ b/tests/test_insert_plan.cpp @@ -5,12 +5,13 @@ // proof that the forbidden stretch bit is never set and that native-length insert // carries no tempo bits. -#include "../src/insert_plan.h" +#include "../src/core/capture/insert_plan.h" #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp index 08ffaa6..43467c0 100644 --- a/tests/test_instrument_drop.cpp +++ b/tests/test_instrument_drop.cpp @@ -6,8 +6,8 @@ // container -> the instrument's OWN reader -> assert the capture selected) IS the // cross-artifact contract guard — the same pattern assignment_request_tests uses. -#include "../src/instrument_drop.h" -#include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader +#include "../src/core/wire/instrument_drop.h" +#include "../src/core/instrument/map/sample_map.h" // deserializeComponentState — the instrument's OWN reader #include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — pins the per-channel class ID @@ -17,6 +17,7 @@ #include using namespace reasampler; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_json.cpp b/tests/test_json.cpp index 687042c..0c71cbe 100644 --- a/tests/test_json.cpp +++ b/tests/test_json.cpp @@ -16,6 +16,7 @@ #include using namespace reasampler; +using namespace reasampler::json; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index 57596cf..61bbc4b 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::keyboard_strip — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the capture-first editor's // keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta // note resolver directly — the geometry that backs the single-capture root-set and the opt-in @@ -14,11 +14,12 @@ // no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with // out-of-range inputs that clamp to [0,127]. -#include "../src/vst/keyboard_strip.h" +#include "../src/core/instrument/ui/keyboard_strip.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -32,13 +33,13 @@ static StripLayout wideStrip() { return layoutStrip(1280, 40); } static void testLayoutNormalArea() { const StripLayout L = layoutStrip(640, 40); - CHECK(L.keys.left == 0 && L.keys.top == 0); - CHECK(L.keys.right == 640 && L.keys.bottom == 40); + CHECK(L.keys.x == 0 && L.keys.y == 0); + CHECK(L.keys.right() == 640 && L.keys.bottom() == 40); } static void testLayoutZeroArea() { const StripLayout L = layoutStrip(0, 0); - CHECK(L.keys.width() == 0 && L.keys.height() == 0); + CHECK(L.keys.width == 0 && L.keys.height == 0); } // --- keyLeftX / keyRect / rootMarkerRect -------------------------------------- @@ -46,8 +47,8 @@ static void testLayoutZeroArea() { static void testKeyLeftMonotonicAndBounds() { const StripLayout L = wideStrip(); // Key 0's left edge is the band left; the 128 boundary is the band right. - CHECK(keyLeftX(L, 0) == L.keys.left); - CHECK(keyLeftX(L, 128) == L.keys.right); + CHECK(keyLeftX(L, 0) == L.keys.x); + CHECK(keyLeftX(L, 128) == L.keys.right()); // Strictly non-decreasing across the span. int prev = keyLeftX(L, 0); for (int n = 1; n <= 128; ++n) { @@ -62,17 +63,17 @@ static void testKeyLeftMonotonicAndBounds() { static void testKeyRectHalfOpen() { const StripLayout L = wideStrip(); const Rect k = keyRect(L, 60); - CHECK(k.left == keyLeftX(L, 60)); - CHECK(k.right == keyLeftX(L, 61)); - CHECK(k.top == L.keys.top && k.bottom == L.keys.bottom); - CHECK(k.width() == 10); // 10px/key + CHECK(k.x == keyLeftX(L, 60)); + CHECK(k.right() == keyLeftX(L, 61)); + CHECK(k.y == L.keys.y && k.bottom() == L.keys.bottom()); + CHECK(k.width == 10); // 10px/key } static void testRootMarkerEqualsKeyRect() { const StripLayout L = wideStrip(); const Rect m = rootMarkerRect(L, 64); const Rect k = keyRect(L, 64); - CHECK(m.left == k.left && m.right == k.right && m.top == k.top && m.bottom == k.bottom); + CHECK(m.x == k.x && m.right() == k.right() && m.y == k.y && m.bottom() == k.bottom()); } // --- keyAtPoint --------------------------------------------------------------- @@ -81,17 +82,17 @@ static void testKeyAtPointInverts() { const StripLayout L = wideStrip(); // A point in the middle of key 60's cell resolves to 60. const Rect k = keyRect(L, 60); - CHECK(keyAtPoint(L, k.left + 5, k.top + 2) == 60); + CHECK(keyAtPoint(L, k.x + 5, k.y + 2) == 60); // The very left of the band is key 0; just inside the right edge is key 127. - CHECK(keyAtPoint(L, L.keys.left, 2) == 0); - CHECK(keyAtPoint(L, L.keys.right - 1, 2) == 127); + CHECK(keyAtPoint(L, L.keys.x, 2) == 0); + CHECK(keyAtPoint(L, L.keys.right() - 1, 2) == 127); } static void testKeyAtPointOffBand() { const StripLayout L = wideStrip(); CHECK(keyAtPoint(L, -5, 2) == -1); // left of band - CHECK(keyAtPoint(L, L.keys.right + 5, 2) == -1); // right of band - CHECK(keyAtPoint(L, 100, L.keys.bottom + 5) == -1); // below band + CHECK(keyAtPoint(L, L.keys.right() + 5, 2) == -1); // right of band + CHECK(keyAtPoint(L, 100, L.keys.bottom() + 5) == -1); // below band } // --- zoneBarRect -------------------------------------------------------------- @@ -99,17 +100,17 @@ static void testKeyAtPointOffBand() { static void testZoneBarSpansInclusive() { const StripLayout L = wideStrip(); const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive - CHECK(bar.left == keyLeftX(L, 12)); - CHECK(bar.right == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully - CHECK(bar.width() == 120); // 12 keys * 10px + CHECK(bar.x == keyLeftX(L, 12)); + CHECK(bar.right() == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully + CHECK(bar.width == 120); // 12 keys * 10px } static void testZoneBarMalformedCollapses() { const StripLayout L = wideStrip(); // low > high must collapse, never invert. const Rect bar = zoneBarRect(L, 80, 40); - CHECK(bar.width() >= 0); - CHECK(bar.right >= bar.left); + CHECK(bar.width >= 0); + CHECK(bar.right() >= bar.x); } // --- zoneGrabAt --------------------------------------------------------------- @@ -117,23 +118,23 @@ static void testZoneBarMalformedCollapses() { static void testZoneGrabEdgesAndBody() { const StripLayout L = wideStrip(); const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body - const int y = L.keys.top + 2; + const int y = L.keys.y + 2; // Near the left edge -> low; near the right edge -> high; the middle -> body. - CHECK(zoneGrabAt(L, 20, 60, bar.left + 1, y) == ZoneGrab::kLowEdge); - CHECK(zoneGrabAt(L, 20, 60, bar.right - 1, y) == ZoneGrab::kHighEdge); - CHECK(zoneGrabAt(L, 20, 60, bar.left + bar.width() / 2, y) == ZoneGrab::kBody); + CHECK(zoneGrabAt(L, 20, 60, bar.x + 1, y) == ZoneGrab::kLowEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.right() - 1, y) == ZoneGrab::kHighEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.x + bar.width / 2, y) == ZoneGrab::kBody); // Off the bar entirely -> none. - CHECK(zoneGrabAt(L, 20, 60, bar.right + 20, y) == ZoneGrab::kNone); + CHECK(zoneGrabAt(L, 20, 60, bar.right() + 20, y) == ZoneGrab::kNone); } static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() { const StripLayout L = wideStrip(); // A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint. const Rect bar = zoneBarRect(L, 50, 50); - const int y = L.keys.top + 2; - const int mid = bar.left + bar.width() / 2; + const int y = L.keys.y + 2; + const int mid = bar.x + bar.width / 2; CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low - CHECK(zoneGrabAt(L, 50, 50, bar.right - 1, y) == ZoneGrab::kHighEdge); + CHECK(zoneGrabAt(L, 50, 50, bar.right() - 1, y) == ZoneGrab::kHighEdge); } // --- zoneBarAtPoint ----------------------------------------------------------- @@ -143,8 +144,8 @@ static void testZoneBarAtPointFirstMatch() { const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50] const int highs[2] = {50, 70}; const Rect overlap = zoneBarRect(L, 30, 50); - const int y = L.keys.top + 2; - const int cx = overlap.left + overlap.width() / 2; + const int y = L.keys.y + 2; + const int cx = overlap.x + overlap.width / 2; // A point in the overlap resolves to the FIRST covering zone (draw order). const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y); CHECK(hit.zoneIndex == 0); @@ -219,7 +220,7 @@ static void testResolveDragProportionalNonDivisibleWidth() { // a drag from note 0 by (width-1) pixels must land at keyAtPoint(width-1), which is 127. const int width = 544; const StripLayout L = layoutStrip(width, 40); - CHECK(keyAtPoint(L, width - 1, L.keys.top + 1) == 127); + CHECK(keyAtPoint(L, width - 1, L.keys.y + 1) == 127); CHECK(resolveDragNote(L, 0, width - 1) == 127); // Also verify mid-strip coherence: for each key N, a drag from 0 by N's left-edge @@ -227,12 +228,12 @@ static void testResolveDragProportionalNonDivisibleWidth() { // rounding may round down). The critical direction is that it must NOT over-shoot by // more than 0 (it must reach at least the right key). for (int n = 1; n < kStripKeyCount; ++n) { - const int leftPx = keyRect(L, n).left; + const int leftPx = keyRect(L, n).x; const int resolved = resolveDragNote(L, 0, leftPx); // The left edge of key N is the first pixel "in" that key, so we expect resolved == N. // Allow resolved == N-1 only when the pixel is at the exact boundary (keyEdgeToX may // produce the same x for adjacent keys when keys share a pixel). Disallow over-shoot. - const int expected = keyAtPoint(L, leftPx, L.keys.top + 1); + const int expected = keyAtPoint(L, leftPx, L.keys.y + 1); CHECK(resolved >= expected - 1 && resolved <= expected + 1); } } diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index 695c6fa..4852617 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::knob_deck — no VST3, no REAPER, no framework. Same fast +// Standalone tests for reasampler::instrument::ui::knob_deck — no VST3, no REAPER, no framework. Same fast // assert loop as the sibling pure tests. Assert the r11 deck layout HARD: // // * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths. @@ -10,12 +10,13 @@ // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells // and fence padding miss, outside-deck miss. -#include "../src/vst/knob_deck.h" +#include "../src/core/instrument/ui/knob_deck.h" #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -75,20 +76,20 @@ static void testWrapAtNarrowWidthIsDeterministic() { CHECK(dl.groups.size() == 5); // Row membership: groups on row 1 share the first top; the wrapped groups sit one row // pitch lower and restart at the left margin. - const int row0Top = dl.groups[0].box.top; + const int row0Top = dl.groups[0].box.y; const int row1Top = row0Top + kDeckGroupH + kDeckRowGap; - CHECK(dl.groups[0].box.top == row0Top); - CHECK(dl.groups[1].box.top == row0Top); + CHECK(dl.groups[0].box.y == row0Top); + CHECK(dl.groups[1].box.y == row0Top); bool sawWrap = false; for (std::size_t i = 1; i < dl.groups.size(); ++i) { - if (dl.groups[i].box.top == row1Top && dl.groups[i - 1].box.top == row0Top) { - CHECK(dl.groups[i].box.left == 8); // wrapped row restarts at the left edge + if (dl.groups[i].box.y == row1Top && dl.groups[i - 1].box.y == row0Top) { + CHECK(dl.groups[i].box.x == 8); // wrapped row restarts at the left edge sawWrap = true; } } CHECK(sawWrap); // Every box stays within the available width (no group straddles the right edge). - for (const auto& g : dl.groups) CHECK(g.box.right <= 8 + 544); + for (const auto& g : dl.groups) CHECK(g.box.right() <= 8 + 544); } static void testFirstGroupAlwaysPlaces() { @@ -103,33 +104,33 @@ static void testGroupInnerGeometry() { const DeckLayout dl = layoutDeck(deck, 8, 50, 824); const DeckGroupLayout& amp = dl.groups[0]; // Caption row at the top padding; caption toggle right-anchored inside the box. - CHECK(amp.caption.top == amp.box.top + kDeckGroupPadY); + CHECK(amp.caption.y == amp.box.y + kDeckGroupPadY); CHECK(amp.captionToggle.id == 100); - CHECK(amp.captionToggle.seg1.right == amp.box.right - kDeckGroupPadX); - CHECK(amp.captionToggle.seg0.right == amp.captionToggle.seg1.left); - CHECK(amp.captionToggle.seg0.width() == 44 && amp.captionToggle.seg1.width() == 44); - CHECK(amp.captionToggle.seg0.height() == kDeckToggleH); + CHECK(amp.captionToggle.seg1.right() == amp.box.right() - kDeckGroupPadX); + CHECK(amp.captionToggle.seg0.right() == amp.captionToggle.seg1.x); + CHECK(amp.captionToggle.seg0.width == 44 && amp.captionToggle.seg1.width == 44); + CHECK(amp.captionToggle.seg0.height == kDeckToggleH); // The caption text rect stops before the toggle. - CHECK(amp.caption.right <= amp.captionToggle.seg0.left); + CHECK(amp.caption.right() <= amp.captionToggle.seg0.x); // Cells: five, fixed size, abutting, inside the box, below the caption row. CHECK(static_cast(amp.cells.size()) == 5); for (std::size_t i = 0; i < amp.cells.size(); ++i) { const DeckCellLayout& c = amp.cells[i]; - CHECK(c.cell.width() == kDeckCellW && c.cell.height() == kDeckCellH); - CHECK(c.cell.top == amp.box.top + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap); - if (i > 0) CHECK(c.cell.left == amp.cells[i - 1].cell.right); + CHECK(c.cell.width == kDeckCellW && c.cell.height == kDeckCellH); + CHECK(c.cell.y == amp.box.y + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap); + if (i > 0) CHECK(c.cell.x == amp.cells[i - 1].cell.right()); // Knob square centered horizontally, label band beneath it, both inside the cell. - CHECK(c.knob.width() == kDeckKnobSize && c.knob.height() == kDeckKnobSize); - CHECK(c.knob.left - c.cell.left == c.cell.right - c.knob.right); - CHECK(c.label.top >= c.knob.bottom); - CHECK(c.label.bottom <= c.cell.bottom); + CHECK(c.knob.width == kDeckKnobSize && c.knob.height == kDeckKnobSize); + CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right()); + CHECK(c.label.y >= c.knob.bottom()); + CHECK(c.label.bottom() <= c.cell.bottom()); } // VOICE group's row toggle sits after its cell, vertically centered in the cell row. const DeckGroupLayout& voice = dl.groups[3]; CHECK(voice.rowToggle.id == 104); - CHECK(voice.rowToggle.seg0.left == voice.cells[0].cell.right + kDeckToggleGap); - CHECK(voice.rowToggle.seg0.height() == kDeckToggleH); - CHECK(voice.rowToggle.seg0.top > voice.cells[0].cell.top); + CHECK(voice.rowToggle.seg0.x == voice.cells[0].cell.right() + kDeckToggleGap); + CHECK(voice.rowToggle.seg0.height == kDeckToggleH); + CHECK(voice.rowToggle.seg0.y > voice.cells[0].cell.y); // MASTER has no toggles. CHECK(dl.groups[4].captionToggle.id == -1); CHECK(dl.groups[4].rowToggle.id == -1); @@ -142,20 +143,20 @@ static void testHitTest() { // Knob hit: anywhere in the cell (including the label band) resolves to the cell id. const DeckCellLayout& c0 = amp.cells[0]; - DeckHit h = hitTestDeck(dl, c0.cell.left + 1, c0.cell.top + 1); + DeckHit h = hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1); CHECK(h.kind == DeckHitKind::Knob && h.id == 1 && h.segment == -1); - h = hitTestDeck(dl, c0.label.left + 2, c0.label.top + 2); + h = hitTestDeck(dl, c0.label.x + 2, c0.label.y + 2); CHECK(h.kind == DeckHitKind::Knob && h.id == 1); // Caption toggle segments 0/1 at their boundary: last px of seg0, first px of seg1. - h = hitTestDeck(dl, amp.captionToggle.seg0.right - 1, amp.captionToggle.seg0.top + 1); + h = hitTestDeck(dl, amp.captionToggle.seg0.right() - 1, amp.captionToggle.seg0.y + 1); CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 0); - h = hitTestDeck(dl, amp.captionToggle.seg1.left, amp.captionToggle.seg1.top + 1); + h = hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1); CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 1); // Row toggle. const DeckGroupLayout& voice = dl.groups[3]; - h = hitTestDeck(dl, voice.rowToggle.seg1.left + 1, voice.rowToggle.seg1.top + 1); + h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1); CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1); // A blank cell (id -1) misses even though its rect exists. @@ -164,11 +165,11 @@ static void testHitTest() { const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckCellLayout& blank = tl.groups[0].cells[4]; CHECK(blank.id == -1); - h = hitTestDeck(tl, blank.cell.left + 5, blank.cell.top + 5); + h = hitTestDeck(tl, blank.cell.x + 5, blank.cell.y + 5); CHECK(h.kind == DeckHitKind::None); // The fence padding inside the box misses; outside the deck misses. - h = hitTestDeck(dl, amp.box.left + 1, amp.box.bottom - 1); + h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1); CHECK(h.kind == DeckHitKind::None); h = hitTestDeck(dl, -50, -50); CHECK(h.kind == DeckHitKind::None); diff --git a/tests/test_lane_keys.cpp b/tests/test_lane_keys.cpp index 82a280b..7cfdd0b 100644 --- a/tests/test_lane_keys.cpp +++ b/tests/test_lane_keys.cpp @@ -9,12 +9,13 @@ // 3. Round-trip: managedLaneKey(laneNameForMode(m)) == "reasampler:" + m, so the // Wave-3 minting path and the read path cannot drift. -#include "../src/lane_keys.h" +#include "../src/core/view/lane_keys.h" #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_master_gain.cpp b/tests/test_master_gain.cpp index 16be668..50b098e 100644 --- a/tests/test_master_gain.cpp +++ b/tests/test_master_gain.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::master_gain — no VST3, no REAPER, no framework. Same +// Standalone tests for reasampler::instrument::engine::master_gain — no VST3, no REAPER, no framework. Same // fast assert loop as the sibling pure tests. Assert the FB1 post-mixer gain taper HARD: // // * -inf bottom — norm 0 maps to -infinity dB and TRUE ZERO linear (silence, not an epsilon); @@ -9,13 +9,14 @@ // * monotonicity — more norm never means less gain. // * label — "-inf" at the bottom, signed one-decimal dB elsewhere. -#include "../src/vst/master_gain.h" +#include "../src/core/instrument/engine/master_gain.h" #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_mode_enable.cpp b/tests/test_mode_enable.cpp index 2e8effc..d8fe990 100644 --- a/tests/test_mode_enable.cpp +++ b/tests/test_mode_enable.cpp @@ -7,13 +7,14 @@ // buttons are live and "…: Design" buttons are dead; when Arrange is active, the reverse. An // unrecognized active id fails OPEN (every button live) so a future mode never dead-locks the bar. -#include "../src/mode_enable.h" -#include "../src/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ids the rule keys off +#include "../src/core/ui/mode_enable.h" +#include "../src/core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ids the rule keys off #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_mode_switch.cpp b/tests/test_mode_switch.cpp index 3110165..42a84c3 100644 --- a/tests/test_mode_switch.cpp +++ b/tests/test_mode_switch.cpp @@ -8,13 +8,14 @@ // evenly); hit-test hits per segment and misses (outside the band above/below/ // left/right, boundary pixels); degenerate widths and counts. -#include "../src/mode_switch.h" +#include "../src/core/view/mode_switch.h" #include #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_note_entry.cpp b/tests/test_note_entry.cpp index 4990001..1107f36 100644 --- a/tests/test_note_entry.cpp +++ b/tests/test_note_entry.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::map::note_entry — no VST3, no REAPER, no framework. // Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note. // // Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the @@ -6,11 +6,12 @@ // [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt; // the integer path taking precedence over the note-name path for a leading digit. -#include "../src/vst/note_entry.h" +#include "../src/core/instrument/map/note_entry.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_overflow_menu.cpp b/tests/test_overflow_menu.cpp index 0a064bb..c6912c3 100644 --- a/tests/test_overflow_menu.cpp +++ b/tests/test_overflow_menu.cpp @@ -8,11 +8,12 @@ // a degenerate band reserves nothing; hit-test in/out/edge (half-open bounds); an empty button // claims no point; draw and hit-test agree over the whole rect. -#include "../src/overflow_menu.h" +#include "../src/core/ui/overflow_menu.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_owned_manifest.cpp b/tests/test_owned_manifest.cpp index 77ecf17..e5f27f8 100644 --- a/tests/test_owned_manifest.cpp +++ b/tests/test_owned_manifest.cpp @@ -7,12 +7,13 @@ // semantics, insertion-order preservation, malformed-parse -> nullopt (the persist // shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters. -#include "../src/owned_manifest.h" +#include "../src/core/model/owned_manifest.h" #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp index 35005a4..48dfaed 100644 --- a/tests/test_param_slider.cpp +++ b/tests/test_param_slider.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::param_slider — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip): // assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider // value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing. @@ -16,13 +16,14 @@ // o'clock), wrap-boundary + un-normalized arc inputs, the needle endpoint on the circle, and // the vertical-drag delta->value map (up = increase) with clamping at 0/1. -#include "../src/vst/param_slider.h" +#include "../src/core/instrument/ui/param_slider.h" #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -33,7 +34,7 @@ static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9 // --- layoutControls ----------------------------------------------------------- static void testLayoutStacksRows() { - const Rect panel{0, 100, 300, 400}; + const Rect panel = Rect::ltrb(0, 100, 300, 400); std::vector ctl{ {1, ControlKind::Toggle}, {2, ControlKind::Slider}, @@ -42,58 +43,58 @@ static void testLayoutStacksRows() { const std::vector rows = layoutControls(panel, ctl); CHECK(rows.size() == 3); // Row 0 sits at the panel top; each subsequent row is one row-height + gap below. - CHECK(rows[0].row.top == 100); - CHECK(rows[0].row.bottom == 100 + kControlRowHeight); - CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap); - CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap); + CHECK(rows[0].row.y == 100); + CHECK(rows[0].row.bottom() == 100 + kControlRowHeight); + CHECK(rows[1].row.y == rows[0].row.bottom() + kControlRowGap); + CHECK(rows[2].row.y == rows[1].row.bottom() + kControlRowGap); // Ids + kinds carried through in order. CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle); CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider); // Label column then control column, contiguous, spanning the panel width. - CHECK(rows[0].label.left == panel.left); - CHECK(rows[0].control.left == rows[0].label.right); - CHECK(rows[0].control.right == panel.right); - CHECK(rows[0].label.width() == kControlLabelWidth); + CHECK(rows[0].label.x == panel.x); + CHECK(rows[0].control.x == rows[0].label.right()); + CHECK(rows[0].control.right() == panel.right()); + CHECK(rows[0].label.width == kControlLabelWidth); } static void testLayoutEmptyAndDegenerate() { - CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty()); + CHECK(layoutControls(Rect::ltrb(0, 0, 300, 300), {}).empty()); std::vector ctl{{1, ControlKind::Slider}}; - CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty()); - CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty()); + CHECK(layoutControls(Rect::ltrb(0, 0, 0, 0), ctl).empty()); + CHECK(layoutControls(Rect::ltrb(0, 0, 300, 0), ctl).empty()); } static void testLayoutNarrowPanelClampsLabel() { // A panel narrower than 2*labelWidth clamps the label column to half so a control column // survives. - const Rect panel{0, 0, 100, 200}; + const Rect panel = Rect::ltrb(0, 0, 100, 200); const std::vector rows = layoutControls(panel, {{1, ControlKind::Slider}}); CHECK(rows.size() == 1); - CHECK(rows[0].label.width() <= panel.width() / 2 + 1); - CHECK(rows[0].control.width() > 0); + CHECK(rows[0].label.width <= panel.width / 2 + 1); + CHECK(rows[0].control.width > 0); } // --- toggle ------------------------------------------------------------------- static void testToggleSegmentsTile() { - const Rect control{100, 0, 300, 22}; // width 200 + const Rect control = Rect::ltrb(100, 0, 300, 22); // width 200 const Rect s0 = toggleSegmentRect(control, 0); const Rect s1 = toggleSegmentRect(control, 1); - CHECK(s0.left == 100 && s0.right == 200); - CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right + CHECK(s0.x == 100 && s0.right() == 200); + CHECK(s1.x == 200 && s1.right() == 300); // last absorbs remainder -> reaches control.right() // Out of range. - CHECK(toggleSegmentRect(control, 2).width() == 0); - CHECK(toggleSegmentRect(control, -1).width() == 0); + CHECK(toggleSegmentRect(control, 2).width == 0); + CHECK(toggleSegmentRect(control, -1).width == 0); } static void testToggleSegmentRemainderInLast() { - const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) - CHECK(toggleSegmentRect(control, 0).width() == 100); - CHECK(toggleSegmentRect(control, 1).right == 201); + const Rect control = Rect::ltrb(0, 0, 201, 22); // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) + CHECK(toggleSegmentRect(control, 0).width == 100); + CHECK(toggleSegmentRect(control, 1).right() == 201); } static void testToggleHitTest() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); CHECK(toggleSegmentHitTest(control, 150, 10) == 0); CHECK(toggleSegmentHitTest(control, 250, 10) == 1); CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control @@ -103,59 +104,59 @@ static void testToggleHitTest() { // --- slider ------------------------------------------------------------------- static void testSliderTrackInsetsHalfHandle() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); const Rect track = sliderTrackRect(control); - CHECK(track.left == control.left + kSliderHandleWidth / 2); - CHECK(track.right == control.right - kSliderHandleWidth / 2); + CHECK(track.x == control.x + kSliderHandleWidth / 2); + CHECK(track.right() == control.right() - kSliderHandleWidth / 2); // A control too narrow for a handle yields an empty track. - CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0); + CHECK(sliderTrackRect(Rect::ltrb(0, 0, kSliderHandleWidth - 1, 22)).width == 0); } static void testSliderHandleAtEndpointsAndMid() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); const Rect track = sliderTrackRect(control); const int half = kSliderHandleWidth / 2; - // Value 0 -> handle centered at track.left. + // Value 0 -> handle centered at track.x. const Rect h0 = sliderHandleRect(control, 0.0); - CHECK(h0.left + half == track.left); - // Value 1 -> handle centered at track.right. + CHECK(h0.x + half == track.x); + // Value 1 -> handle centered at track.right(). const Rect h1 = sliderHandleRect(control, 1.0); - CHECK(h1.left + half == track.right); + CHECK(h1.x + half == track.right()); // Value 0.5 -> centered at the track middle. const Rect hm = sliderHandleRect(control, 0.5); - CHECK(hm.left + half == track.left + track.width() / 2); + CHECK(hm.x + half == track.x + track.width / 2); } static void testSliderHandleClampsOutOfRange() { - const Rect control{0, 0, 200, 22}; - CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left); - CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left); + const Rect control = Rect::ltrb(0, 0, 200, 22); + CHECK(sliderHandleRect(control, -0.5).x == sliderHandleRect(control, 0.0).x); + CHECK(sliderHandleRect(control, 5.0).x == sliderHandleRect(control, 1.0).x); } static void testValueAtPointEndpointsSaturate() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); const Rect track = sliderTrackRect(control); - CHECK(approx(valueAtPoint(control, track.left - 20), 0.0)); - CHECK(approx(valueAtPoint(control, track.left), 0.0)); - CHECK(approx(valueAtPoint(control, track.right + 20), 1.0)); - CHECK(approx(valueAtPoint(control, track.right), 1.0)); + CHECK(approx(valueAtPoint(control, track.x - 20), 0.0)); + CHECK(approx(valueAtPoint(control, track.x), 0.0)); + CHECK(approx(valueAtPoint(control, track.right() + 20), 1.0)); + CHECK(approx(valueAtPoint(control, track.right()), 1.0)); } static void testValueAtPointIsHandleInverse() { // Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum). - const Rect control{50, 0, 450, 22}; // wide track for pixel resolution + const Rect control = Rect::ltrb(50, 0, 450, 22); // wide track for pixel resolution const Rect track = sliderTrackRect(control); for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) { const Rect h = sliderHandleRect(control, v); - const int centerX = h.left + kSliderHandleWidth / 2; + const int centerX = h.x + kSliderHandleWidth / 2; const double back = valueAtPoint(control, centerX); CHECK(back >= v - 0.01 && back <= v + 0.01); - CHECK(centerX >= track.left && centerX <= track.right); + CHECK(centerX >= track.x && centerX <= track.right()); } } static void testValueAtPointDegenerateTrack() { - CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); + CHECK(approx(valueAtPoint(Rect::ltrb(0, 0, kSliderHandleWidth - 1, 22), 5), 0.0)); } // --- knob (FA4) ----------------------------------------------------------------- @@ -164,21 +165,21 @@ static bool nearWithin(double a, double b, double tol) { return (a - b) < tol && static void testKnobGeometryInscribesCell() { // A 44x44 cell at (100,0): center (122,22), radius 22. - const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); + const KnobGeometry g = computeKnob(Rect::ltrb(100, 0, 144, 44)); CHECK(approx(g.centerX, 122.0)); CHECK(approx(g.centerY, 22.0)); CHECK(approx(g.radius, 22.0)); // A wide cell inscribes on the smaller (vertical) dimension. - const KnobGeometry w = computeKnob(Rect{0, 0, 200, 22}); + const KnobGeometry w = computeKnob(Rect::ltrb(0, 0, 200, 22)); CHECK(approx(w.radius, 11.0)); CHECK(approx(w.centerX, 100.0)); // Degenerate cells yield radius 0. - CHECK(computeKnob(Rect{0, 0, 0, 22}).radius == 0.0); - CHECK(computeKnob(Rect{0, 0, 22, 0}).radius == 0.0); + CHECK(computeKnob(Rect::ltrb(0, 0, 0, 22)).radius == 0.0); + CHECK(computeKnob(Rect::ltrb(0, 0, 22, 0)).radius == 0.0); } static void testKnobHitTestCircle() { - const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 + const KnobGeometry g = computeKnob(Rect::ltrb(100, 0, 144, 44)); // center (122,22), r 22 CHECK(knobHitTest(g, 122, 22)); // center — always hits CHECK(!knobHitTest(g, 122 + 22, 22)); // exactly on the boundary — boundary exclusive CHECK(!knobHitTest(g, 122 + 22, 44)); // cell corner: inside the rect, outside the circle @@ -223,7 +224,7 @@ static void testKnobArcWrapBoundary() { } static void testKnobNeedlePointOnCircle() { - const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 + const KnobGeometry g = computeKnob(Rect::ltrb(100, 0, 144, 44)); // center (122,22), r 22 // Default arc, value 0 -> 7 o'clock -> needle points down-left from center. const KnobPoint p7 = knobNeedlePoint(g, KnobArc{}, 0.0); // 210° clockwise from 12: sin(210°)=-0.5, cos(210°)=-√3/2 -> x = cx - r/2, y = cy + r*√3/2 @@ -265,7 +266,7 @@ static void testKnobDragClamps() { // --- controlAtPoint routing --------------------------------------------------- static void testControlAtPointRoutes() { - const Rect panel{0, 0, 300, 400}; + const Rect panel = Rect::ltrb(0, 0, 300, 400); std::vector ctl{ {10, ControlKind::Toggle}, {20, ControlKind::Slider}, @@ -274,26 +275,26 @@ static void testControlAtPointRoutes() { const std::vector rows = layoutControls(panel, ctl); // A point in the toggle's control area routes to the toggle id. const Rect tctl = rows[0].control; - CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10); + CHECK(controlAtPoint(rows, (tctl.x + tctl.right()) / 2, (tctl.y + tctl.bottom()) / 2) == 10); // A point on the slider's track routes to the slider id. const Rect strack = sliderTrackRect(rows[1].control); - CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, - (strack.top + strack.bottom) / 2) == 20); + CHECK(controlAtPoint(rows, (strack.x + strack.right()) / 2, + (strack.y + strack.bottom()) / 2) == 20); // A point at the knob's center routes to the knob id; the control-rect corner (outside // the circle) is a miss. const KnobGeometry kg = computeKnob(rows[2].control); CHECK(controlAtPoint(rows, static_cast(kg.centerX), static_cast(kg.centerY)) == 30); - CHECK(controlAtPoint(rows, rows[2].control.left + 1, rows[2].control.top + 1) == -1); + CHECK(controlAtPoint(rows, rows[2].control.x + 1, rows[2].control.y + 1) == -1); } static void testControlAtPointMisses() { - const Rect panel{0, 0, 300, 400}; + const Rect panel = Rect::ltrb(0, 0, 300, 400); const std::vector rows = layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}}); // The label column is not interactive. - CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1); + CHECK(controlAtPoint(rows, rows[0].label.x + 2, rows[0].label.y + 4) == -1); // The gap between rows is a miss. - const int gapY = rows[0].row.bottom + kControlRowGap / 2; + const int gapY = rows[0].row.bottom() + kControlRowGap / 2; CHECK(controlAtPoint(rows, 200, gapY) == -1); // Off-panel below. CHECK(controlAtPoint(rows, 200, 5000) == -1); diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp index 713f70b..4ac91b4 100644 --- a/tests/test_peaks.cpp +++ b/tests/test_peaks.cpp @@ -11,13 +11,14 @@ // merge, steep disjoint-span merge, no-bin-dropped spike sweep, no-column-empty // coverage, col clamp, degenerate inputs. -#include "../src/peaks.h" +#include "../src/core/audio/peaks.h" #include #include #include using namespace reasampler; +using namespace reasampler::audio; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index a92c2fe..768e45e 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -27,13 +27,14 @@ // the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on // decorrelated stereo content where an independent per-channel search provably diverges. -#include "../src/vst/pitch_shift.h" +#include "../src/core/instrument/engine/pitch_shift.h" #include #include #include using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_provenance.cpp b/tests/test_provenance.cpp index a2b19f3..5c77186 100644 --- a/tests/test_provenance.cpp +++ b/tests/test_provenance.cpp @@ -15,15 +15,16 @@ // The Sample-JSON round-trip of the fingerprint (leveraging M1's existing provenance // round-trip) is exercised in test_bank_model.cpp — see the fingerprint case there. -#include "../src/provenance.h" +#include "../src/core/model/provenance.h" -#include "../src/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam) +#include "../src/core/model/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam) #include #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -246,7 +247,7 @@ static void testHugeGuidCountRejectedBeforeReserve() { // --- recorded-recipe model round-trips through the Sample JSON ---------------- // The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's -// BankIndex JSON already round-trips. Prove a real recipe survives that path intact. +// BankModel JSON already round-trips. Prove a real recipe survives that path intact. static void testRecipeThroughSampleJson() { const CaptureRecipe r = baseRecipe(); @@ -260,10 +261,10 @@ static void testRecipeThroughSampleJson() { prov.fxChainSnapshot = buildFingerprint(r); s.provenance = prov; - BankIndex idx; + BankModel idx; CHECK(idx.add(s) == AddResult::Added); - auto back = BankIndex::deserialize(idx.serialize()); + auto back = BankModel::deserialize(idx.serialize()); CHECK(back.has_value()); const Sample* child = back ? back->query("child-1") : nullptr; CHECK(child != nullptr); diff --git a/tests/test_prune_button.cpp b/tests/test_prune_button.cpp index 19ff0aa..2803d64 100644 --- a/tests/test_prune_button.cpp +++ b/tests/test_prune_button.cpp @@ -7,11 +7,12 @@ // tail-label inset or is degenerate; hit-test in/out/edge (half-open bounds); a // suppressed/empty button claims no point; draw and hit-test agree over the whole rect. -#include "../src/prune_button.h" +#include "../src/core/ui/prune_button.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_prune_reconcile.cpp b/tests/test_prune_reconcile.cpp index 8a283d1..2fabda3 100644 --- a/tests/test_prune_reconcile.cpp +++ b/tests/test_prune_reconcile.cpp @@ -15,8 +15,8 @@ // Plus: determinism (present-order output), duplicate-`present` de-dup, exact-string // (non-normalizing) match, and the referencedPaths() union query directly. -#include "../src/bank_book.h" -#include "../src/prune_reconcile.h" +#include "../src/core/model/bank_book.h" +#include "../src/core/reclaim/prune_reconcile.h" #include #include @@ -26,6 +26,8 @@ #include using namespace reasampler; +using namespace reasampler::model; +using namespace reasampler::reclaim; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_realtime_record.cpp b/tests/test_realtime_record.cpp index c904ecc..1da6e96 100644 --- a/tests/test_realtime_record.cpp +++ b/tests/test_realtime_record.cpp @@ -3,12 +3,13 @@ // record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS) // and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping. -#include "../src/realtime_record.h" +#include "../src/core/capture/realtime_record.h" #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 62001c1..fc30425 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -6,7 +6,7 @@ // FX-bypass plan (corrects the "items captured through parent FX" defect), and the // capture-action taxonomy table (stable ids, scope x tail-variant matrix). -#include "../src/render_settings.h" +#include "../src/core/capture/render_settings.h" #include #include @@ -14,6 +14,7 @@ #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 427cbcb..1a7a96d 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -22,7 +22,7 @@ // stride contract across the seam (that the byte stride wav_trim reports matches the // channel-count stride downmixToMono divides by). -#include "../src/vst/sample_map.h" +#include "../src/core/instrument/map/sample_map.h" #include #include @@ -30,18 +30,21 @@ #include #include -#include "../src/bank_book.h" -#include "../src/bank_model.h" -#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap) +#include "../src/core/model/bank_book.h" +#include "../src/core/model/bank_model.h" +#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap) using namespace reasampler; +using namespace reasampler::instrument::engine; +using namespace reasampler::capture; // wav_trim (WavLayout) — sample_map re-exports live in reasampler until Q-W2v +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // Build a Sample with the fields sample_map reads. Relative path is required by -// BankIndex::add (relative-only invariant); a content hash is set so dedup does not +// BankModel::add (relative-only invariant); a content hash is set so dedup does not // collapse distinct entries. static Sample makeSample(const std::string& id, const std::string& name, const std::string& rel, std::optional root) { @@ -62,7 +65,7 @@ static std::string bookJson(const std::vector& poolSamples, for (const Sample& s : poolSamples) book.pool().index.add(s); if (!drumSamples.empty()) { book.createBank("drums-id", "Drums"); - BankIndex* di = book.index("drums-id"); + BankModel* di = book.index("drums-id"); for (const Sample& s : drumSamples) di->add(s); } return book.serialize(); @@ -1305,7 +1308,7 @@ static void testComponentStateMasterGainWriterClamps() { hi.masterGainLinear = 1000.0; CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0) .masterGainLinear - - vst::masterGainMaxLinear()) < 1e-9); + instrument::engine::masterGainMaxLinear()) < 1e-9); ComponentState lo; lo.masterGainLinear = -5.0; CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear == @@ -1686,7 +1689,7 @@ static void testVelocityCurveRoundTrip() { // A second zone left at the flat default proves the field is per-record and defaults to flat y=1. PerformanceMap m; PerformanceZone z = zone("lead", 20, 100); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); z.velocityCurve.addPoint(60.0, 0.3); // an interior knot to exercise multi-point round-trip m.zones.push_back(z); m.zones.push_back(zone("pad", 0, 19)); // default flat curve @@ -1694,7 +1697,7 @@ static void testVelocityCurveRoundTrip() { CHECK(back.zones.size() == 2); if (back.zones.size() != 2) return; CHECK(back.zones[0].velocityCurve.equals(z.velocityCurve)); // exact point round-trip - CHECK(back.zones[1].velocityCurve.equals(vst::VelocityCurve::flat())); // default preserved + CHECK(back.zones[1].velocityCurve.equals(VelocityCurve::flat())); // default preserved // And the flat default really is unity everywhere (R10-F1 Option A), not the old linear ramp. CHECK(back.zones[1].velocityCurve.eval(1.0) == 1.0); CHECK(back.zones[1].velocityCurve.eval(64.0) == 1.0); @@ -1706,12 +1709,12 @@ static void testVelocityCurveThroughComponentEnvelope() { ComponentState s; s.selectionId = "pick"; PerformanceZone z = zone("pick", 0, 127); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); s.map.zones.push_back(z); const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.map.zones.size() == 1); if (back.map.zones.size() != 1) return; - CHECK(back.map.zones[0].velocityCurve.equals(vst::VelocityCurve::linear())); + CHECK(back.map.zones[0].velocityCurve.equals(VelocityCurve::linear())); } static void testVelocityCurveResolvesToZone() { @@ -1720,12 +1723,12 @@ static void testVelocityCurveResolvesToZone() { const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); PerformanceMap m; PerformanceZone z = zone("a", 0, 127); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); m.zones.push_back(z); const ResolvedPerformance r = resolvePerformance(json, m); CHECK(r.zones.size() == 1); if (r.zones.size() != 1) return; - CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear())); + CHECK(r.zones[0].velocityCurve.equals(VelocityCurve::linear())); } // FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition @@ -1740,7 +1743,7 @@ static void testVelocityCurveEndToEndThroughReloadComposition() { ComponentState s; s.selectionId = "a"; PerformanceZone z = zone("a", 0, 127); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); s.map.zones.push_back(z); const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0); CHECK(back.map.zones.size() == 1); @@ -1817,7 +1820,7 @@ static void testVelocityCurveV6BackCompatLiftsToFlat() { CHECK(back.zones[0].sampleId == "v6saved"); CHECK(back.zones[0].keyTrack == 0.5); // the v6 field still read correctly // No curve tail -> flat y=1 default (the deliberate behavior change). - CHECK(back.zones[0].velocityCurve.equals(vst::VelocityCurve::flat())); + CHECK(back.zones[0].velocityCurve.equals(VelocityCurve::flat())); CHECK(back.zones[0].velocityCurve.eval(20.0) == 1.0); // a soft hit now plays at unity } diff --git a/tests/test_sample_usage.cpp b/tests/test_sample_usage.cpp index c2d84b0..8c48da5 100644 --- a/tests/test_sample_usage.cpp +++ b/tests/test_sample_usage.cpp @@ -22,7 +22,7 @@ // the pure identity matcher (UID hex / module filename base / display name, beta // over-protect), and the composed pruneOrphans exclusion proof. -#include "../src/sample_usage.h" +#include "../src/core/wire/sample_usage.h" #include #include @@ -30,9 +30,11 @@ #include #include -#include "../src/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof) +#include "../src/core/reclaim/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof) using namespace reasampler; +using namespace reasampler::reclaim; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 279953c..ec968c9 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -17,7 +17,7 @@ // by the CMake target linking neither SDK — this file includes only sampler_core.h + // the standard library, which is itself the compile-time proof. -#include "../src/vst/sampler_core.h" +#include "../src/core/instrument/engine/sampler_core.h" #include #include @@ -25,6 +25,7 @@ #include using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -426,7 +427,7 @@ static void testNoteOffReleasesNewestSameNote() { // A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default // flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this // note-off-selection test relies on — so we opt this zone back to the linear response. - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); VoiceEngine eng(8, km); std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain @@ -751,7 +752,7 @@ static void testVelocityDefaultCurveIsFlatUnity() { // (not a hardcoded map) drives the gain, and that eval is applied at note-on. static void testVelocityLinearCurveReproducesRamp() { Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); { VoiceEngine eng(1, km); eng.noteOn(60, 127); @@ -776,7 +777,7 @@ static void testVelocityLinearCurveReproducesRamp() { // curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies. static void testVelocityShapedCurveDrivesGain() { Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 - vst::VelocityCurve curve = vst::VelocityCurve::linear(); + VelocityCurve curve = VelocityCurve::linear(); curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9 km.zones[0].velocityCurve = curve; VoiceEngine eng(1, km); @@ -1429,7 +1430,7 @@ static void testVelocityCurveAppliesUnderPreserve() { SampleData s = dcSample(4000, 60); s.play.pitchEngine = PitchEngine::Preserve; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256); eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion) std::vector out; @@ -1578,7 +1579,7 @@ static void testMonoRepressHeldNoteMovesToTop() { static void testMonoRetriggerFallbackUsesOriginalVelocity() { SampleData s = dcLevelSample(200000, 1.0f, 60); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); // gain = velocity/127 + km.zones[0].velocityCurve = VelocityCurve::linear(); // gain = velocity/127 VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); eng.noteOn(60, 32); // soft first note CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4)); @@ -1645,7 +1646,7 @@ static void testMonoLegatoRetunesWithoutReadRestart() { s.rootNote = 60; s.play.adsr = flatAdsr(); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato); eng.noteOn(60, 127); // unity: read advances 1/frame, full gain std::vector out; diff --git a/tests/test_tab_strip.cpp b/tests/test_tab_strip.cpp index ab32613..833cfb5 100644 --- a/tests/test_tab_strip.cpp +++ b/tests/test_tab_strip.cpp @@ -10,13 +10,14 @@ // left/right chevron precedence at the ends, dead space between visible tabs, // outside the band above/below/left/right, half-open boundary pixels). -#include "../src/tab_strip.h" +#include "../src/core/ui/tab_strip.h" #include #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp index 20738ad..20e6e27 100644 --- a/tests/test_tail_control.cpp +++ b/tests/test_tail_control.cpp @@ -3,12 +3,13 @@ // (None -> Auto -> Manual -> None), the manual-length clamp to the 8 s cap, and the // toggle label text. The drawing / click hit-testing is DAW-verified in bank_panel. -#include "../src/tail_control.h" +#include "../src/core/capture/tail_control.h" #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 3885752..545d91c 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -8,7 +8,7 @@ // interaction-state transform behaves, the WCAG math is correct against known anchors, and // the Direction C spectral ramp interpolates its endpoints. -#include "../src/theme.h" +#include "../src/core/ui/theme.h" #include #include @@ -16,6 +16,7 @@ #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_tooltip.cpp b/tests/test_tooltip.cpp index 2d7cf11..fef6765 100644 --- a/tests/test_tooltip.cpp +++ b/tests/test_tooltip.cpp @@ -5,12 +5,13 @@ // match); placement BELOW the anchor centred; horizontal clamp at both client edges; the // bottom-edge flip to ABOVE; the both-clip clamp for a tall tooltip; degenerate inputs -> empty. -#include "../src/tooltip.h" +#include "../src/core/ui/tooltip.h" #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_trigger_seam.cpp b/tests/test_trigger_seam.cpp index 58cbcb5..702c44d 100644 --- a/tests/test_trigger_seam.cpp +++ b/tests/test_trigger_seam.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::trigger_seam — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::map::trigger_seam — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. // // Covers: triggerPlayLength (zero play length, startFrame set, startFrame past frameCount, @@ -6,11 +6,12 @@ // (zero play length, rounding); round-trip fidelity; the Finding 1 regression (start-point // set — the case that was broken before this module existed). -#include "../src/vst/trigger_seam.h" +#include "../src/core/instrument/map/trigger_seam.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_velocity_curve.cpp b/tests/test_velocity_curve.cpp index 9baeaab..7e642cc 100644 --- a/tests/test_velocity_curve.cpp +++ b/tests/test_velocity_curve.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::velocity_curve — no VST3, no REAPER, no framework. Same fast +// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast // assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD: // // * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape @@ -11,12 +11,13 @@ // * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls // back to flat() for a sub-2-point list. -#include "../src/vst/velocity_curve.h" +#include "../src/core/instrument/engine/velocity_curve.h" #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 94ce056..34bb1b3 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -15,14 +15,15 @@ // (park-while-parked) so untagged leaves return to visible after toggling back; // guards the in-DAW "all leaves hidden after toggling twice" regression. -#include "../src/view_mode_model.h" -#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys +#include "../src/core/view/view_mode_model.h" +#include "../src/core/view/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys #include #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_view_tree.cpp b/tests/test_view_tree.cpp index f52c131..9a7e06e 100644 --- a/tests/test_view_tree.cpp +++ b/tests/test_view_tree.cpp @@ -3,12 +3,13 @@ // one genuinely pure piece: the I_FOLDERDEPTH walk that turns REAPER's linear // track stream into the parent<->child FolderTree the pure model consumes. -#include "../src/view_tree.h" +#include "../src/core/view/view_tree.h" #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_wav_trim.cpp b/tests/test_wav_trim.cpp index a615be2..10b651d 100644 --- a/tests/test_wav_trim.cpp +++ b/tests/test_wav_trim.cpp @@ -7,7 +7,7 @@ // extraction (whole / tail window / clamp / out-of-range); truncate plan (kept #include @@ -15,6 +15,7 @@ #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index 2969cd9..d5cfd60 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::waveform_view — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's // frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and // the zero-crossing snap — the geometry + snap that back the draggable start/loop markers. @@ -9,61 +9,62 @@ // no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, // no-crossing keeps target, target clamp, degenerate buffers). -#include "../src/vst/waveform_view.h" +#include "../src/core/instrument/ui/waveform_view.h" #include #include -using namespace reasampler::vst; -using reasampler::AudioSample; +using namespace reasampler; +using namespace reasampler::instrument::ui; +using reasampler::audio::AudioSample; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs). -static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000 +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 90); } // width 1000 // --- frameToX / xToFrame ------------------------------------------------------ static void testFrameToXEndpoints() { const Rect a = wideArea(); - CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge - CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge - CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here) + CHECK(frameToX(a, 1000, 0) == a.x); // frame 0 -> left edge + CHECK(frameToX(a, 1000, 1000) == a.right()); // frameCount -> right edge + CHECK(frameToX(a, 1000, 500) == a.x + 500); // midpoint (1:1 here) } static void testFrameToXClampsOutOfRange() { const Rect a = wideArea(); - CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left - CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right + CHECK(frameToX(a, 1000, -50) == a.x); // below 0 pins left + CHECK(frameToX(a, 1000, 5000) == a.right()); // above count pins right } static void testFrameToXDegenerate() { const Rect a = wideArea(); - CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left - const Rect z = Rect{5, 5, 5, 45}; // zero width - CHECK(frameToX(z, 1000, 500) == z.left); + CHECK(frameToX(a, 0, 100) == a.x); // no frames -> left + const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width + CHECK(frameToX(z, 1000, 500) == z.x); } static void testXToFrameInverse() { const Rect a = wideArea(); - CHECK(xToFrame(a, 1000, a.left) == 0); - CHECK(xToFrame(a, 1000, a.right) == 1000); - CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here + CHECK(xToFrame(a, 1000, a.x) == 0); + CHECK(xToFrame(a, 1000, a.right()) == 1000); + CHECK(xToFrame(a, 1000, a.x + 250) == 250); // 1:1 map here } static void testXToFrameClampsOutside() { const Rect a = wideArea(); - CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0 - CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount - CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0 + CHECK(xToFrame(a, 1000, a.x - 100) == 0); // left of area -> 0 + CHECK(xToFrame(a, 1000, a.right() + 100) == 1000); // right of area -> frameCount + CHECK(xToFrame(a, 0, a.x + 10) == 0); // no frames -> 0 } static void testFrameToXRoundTrip() { // Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then // xToFrame should land within a couple frames (rounding both directions). - const Rect a = Rect{0, 0, 800, 60}; + const Rect a = Rect::ltrb(0, 0, 800, 60); for (std::int64_t f = 0; f <= 2000; f += 137) { const int x = frameToX(a, 2000, f); const std::int64_t back = xToFrame(a, 2000, x); @@ -77,39 +78,39 @@ static void testMarkerAtPointGrabsWithinBand() { const Rect a = wideArea(); // Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900. const std::int64_t frames[3] = {100, 500, 900}; - const int midY = a.top + a.height() / 2; - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0); - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1); - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2); + const int midY = a.y + a.height / 2; + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 100, midY) == 0); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 900, midY) == 2); // Within the grab band on either side of the line. - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1); - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500 + kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500 - kMarkerGrabWidth, midY) == 1); } static void testMarkerAtPointMissesBetween() { const Rect a = wideArea(); const std::int64_t frames[3] = {100, 500, 900}; - const int midY = a.top + a.height() / 2; + const int midY = a.y + a.height / 2; // Well away from any marker line. - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 300, midY) == -1); // Off the area vertically. - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500, a.y - 5) == -1); } static void testMarkerAtPointFirstMatchOnOverlap() { const Rect a = wideArea(); // Two markers at the same frame -> first in order wins. const std::int64_t frames[2] = {400, 400}; - const int midY = a.top + a.height() / 2; - CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0); + const int midY = a.y + a.height / 2; + CHECK(markerAtPoint(a, 1000, frames, 2, a.x + 400, midY) == 0); } static void testMarkerAtPointRejectsNullEmpty() { const Rect a = wideArea(); - const int midY = a.top + a.height() / 2; - CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1); + const int midY = a.y + a.height / 2; + CHECK(markerAtPoint(a, 1000, nullptr, 3, a.x + 100, midY) == -1); const std::int64_t frames[1] = {100}; - CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1); + CHECK(markerAtPoint(a, 1000, frames, 0, a.x + 100, midY) == -1); } // --- resolveDragFrame --------------------------------------------------------- @@ -130,7 +131,7 @@ static void testResolveDragFrameClamps() { static void testResolveDragFrameRounds() { // 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is // at the frame centre. Use a scale where a fractional result appears. - const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px + const Rect a = Rect::ltrb(0, 0, 300, 60); // 1000 frames / 300px = 3.33 frames/px // +3px -> 3*1000/300 = 10.0 -> 10 frames. CHECK(resolveDragFrame(a, 1000, 100, 3) == 110); // +1px -> 1000/300 = 3.33 -> rounds to 3. @@ -138,7 +139,7 @@ static void testResolveDragFrameRounds() { } static void testResolveDragFrameDegenerate() { - const Rect z = Rect{0, 0, 0, 60}; // zero width + const Rect z = Rect::ltrb(0, 0, 0, 60); // zero width CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start const Rect a = wideArea(); CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0 diff --git a/tests/test_wire.cpp b/tests/test_wire.cpp index 3f58399..d2420b4 100644 --- a/tests/test_wire.cpp +++ b/tests/test_wire.cpp @@ -14,6 +14,7 @@ #include using namespace reasampler; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ From 2d59bbe35dfbd4384f53bcab81dbfd0be6298a08 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 21:33:51 -0400 Subject: [PATCH 20/40] Q-W1 code-review follow-ups: restore 104 trailing newlines, relocate reasampler_uid.h to core/wire, drop ext_keys namespaces shim, add slot_map_tests, golden serialize literals for 4 modules, namespaces.h/pragma-once ordering sweep. 60/60 green. --- CMakeLists.txt | 19 +- src/actions.h | 2 +- src/bank_panel.h | 2 +- src/core/audio/peaks.cpp | 2 +- src/core/audio/peaks.h | 2 +- src/core/capture/batch_capture.cpp | 2 +- src/core/capture/batch_capture.h | 2 +- src/core/capture/capture_paths.cpp | 2 +- src/core/capture/capture_paths.h | 2 +- src/core/capture/insert_plan.cpp | 2 +- src/core/capture/insert_plan.h | 2 +- src/core/capture/realtime_record.cpp | 2 +- src/core/capture/realtime_record.h | 2 +- src/core/capture/render_settings.cpp | 2 +- src/core/capture/render_settings.h | 2 +- src/core/capture/tail_control.cpp | 2 +- src/core/capture/tail_control.h | 2 +- src/core/capture/wav_trim.cpp | 2 +- src/core/capture/wav_trim.h | 2 +- src/core/instrument/engine/master_gain.cpp | 2 +- src/core/instrument/engine/master_gain.h | 2 +- src/core/instrument/engine/pitch_shift.cpp | 2 +- src/core/instrument/engine/pitch_shift.h | 2 +- src/core/instrument/engine/velocity_curve.cpp | 2 +- src/core/instrument/engine/velocity_curve.h | 2 +- src/core/instrument/map/bank_sync.cpp | 2 +- src/core/instrument/map/bank_sync.h | 2 +- src/core/instrument/map/bridge_marshal.cpp | 2 +- src/core/instrument/map/bridge_marshal.h | 2 +- src/core/instrument/map/note_entry.cpp | 2 +- src/core/instrument/map/note_entry.h | 2 +- src/core/instrument/map/trigger_seam.cpp | 2 +- src/core/instrument/map/trigger_seam.h | 2 +- src/core/instrument/ui/browser_scroll.cpp | 2 +- src/core/instrument/ui/browser_scroll.h | 2 +- src/core/instrument/ui/capture_browser.cpp | 2 +- src/core/instrument/ui/capture_browser.h | 2 +- src/core/instrument/ui/curve_popup.cpp | 2 +- src/core/instrument/ui/curve_popup.h | 2 +- src/core/instrument/ui/editor_geometry.cpp | 2 +- src/core/instrument/ui/editor_geometry.h | 2 +- src/core/instrument/ui/embed_strip.cpp | 2 +- src/core/instrument/ui/embed_strip.h | 2 +- src/core/instrument/ui/envelope_edit.cpp | 2 +- src/core/instrument/ui/envelope_edit.h | 2 +- src/core/instrument/ui/envelope_overlay.cpp | 3 +- src/core/instrument/ui/envelope_overlay.h | 2 +- src/core/instrument/ui/keyboard_strip.cpp | 2 +- src/core/instrument/ui/keyboard_strip.h | 2 +- src/core/instrument/ui/knob_deck.cpp | 2 +- src/core/instrument/ui/knob_deck.h | 2 +- src/core/instrument/ui/param_slider.cpp | 3 +- src/core/instrument/ui/param_slider.h | 2 +- src/core/instrument/ui/waveform_view.cpp | 2 +- src/core/instrument/ui/waveform_view.h | 2 +- src/core/model/bank_model.cpp | 2 +- src/core/model/bank_model.h | 2 +- src/core/model/owned_manifest.cpp | 2 +- src/core/model/owned_manifest.h | 2 +- src/core/model/provenance.cpp | 2 +- src/core/model/provenance.h | 2 +- src/core/reclaim/prune_reconcile.cpp | 2 +- src/core/reclaim/prune_reconcile.h | 2 +- src/core/ui/action_bar.cpp | 2 +- src/core/ui/action_bar.h | 2 +- src/core/ui/bank_grid.cpp | 2 +- src/core/ui/bank_grid.h | 2 +- src/core/ui/card_drag.cpp | 2 +- src/core/ui/card_drag.h | 2 +- src/core/ui/card_meta.cpp | 2 +- src/core/ui/card_meta.h | 2 +- src/core/ui/component_geometry.cpp | 2 +- src/core/ui/component_geometry.h | 2 +- src/core/ui/drag_out.cpp | 2 +- src/core/ui/drag_out.h | 2 +- src/core/ui/footer_bar.cpp | 2 +- src/core/ui/footer_bar.h | 2 +- src/core/ui/mode_enable.cpp | 2 +- src/core/ui/mode_enable.h | 2 +- src/core/ui/overflow_menu.cpp | 2 +- src/core/ui/overflow_menu.h | 2 +- src/core/ui/prune_button.cpp | 2 +- src/core/ui/prune_button.h | 2 +- src/core/ui/tab_strip.cpp | 2 +- src/core/ui/tab_strip.h | 2 +- src/core/ui/theme.cpp | 2 +- src/core/ui/theme.h | 2 +- src/core/ui/tooltip.cpp | 2 +- src/core/ui/tooltip.h | 2 +- src/core/util/file_bytes.cpp | 2 +- src/core/util/file_bytes.h | 2 +- src/core/version/app_version.cpp | 2 +- src/core/version/app_version.h | 2 +- src/core/view/guid_diff.cpp | 2 +- src/core/view/guid_diff.h | 2 +- src/core/view/lane_keys.cpp | 2 +- src/core/view/lane_keys.h | 2 +- src/core/view/mode_switch.cpp | 2 +- src/core/view/mode_switch.h | 2 +- src/core/view/view_tree.cpp | 2 +- src/core/view/view_tree.h | 2 +- src/core/wire/assignment_request.cpp | 2 +- src/core/wire/assignment_request.h | 2 +- src/core/wire/instrument_drop.cpp | 6 +- src/core/wire/instrument_drop.h | 6 +- .../instrument => core/wire}/reasampler_uid.h | 0 src/core/wire/sample_usage.cpp | 2 +- src/core/wire/sample_usage.h | 2 +- src/ext_keys.h | 3 +- src/ingest.h | 2 +- src/persist.h | 2 +- src/shell/actions/drag_out_win.h | 2 +- src/shell/actions/instrument_drop_win.h | 2 +- src/shell/capture/capture.h | 2 +- src/shell/capture/insert.h | 2 +- src/shell/capture/item_read.h | 2 +- src/shell/capture/provenance_shell.h | 2 +- src/shell/capture/track_guid.h | 2 +- src/shell/instrument/reaper_bridge.h | 2 +- src/shell/instrument/reasampler_embed.h | 2 +- src/shell/instrument/reasampler_vst.h | 4 +- src/shell/panel/draw_kit.h | 2 +- src/shell/persist/usage_scan.h | 2 +- src/shell/view/view.h | 2 +- src/vst/reasampler_editor.h | 2 +- src/vst/reasampler_processor.h | 2 +- tests/test_bank_book.cpp | 162 +++---------- tests/test_bank_model.cpp | 25 ++ tests/test_owned_manifest.cpp | 13 + tests/test_slot_map.cpp | 228 ++++++++++++++++++ tests/test_view_mode_model.cpp | 15 ++ 131 files changed, 460 insertions(+), 263 deletions(-) rename src/{shell/instrument => core/wire}/reasampler_uid.h (100%) create mode 100644 tests/test_slot_map.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c9989d..09e9d7b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -249,6 +249,16 @@ target_include_directories(tail_control PUBLIC src) target_link_libraries(tail_control PUBLIC render_settings) target_link_libraries(tail_control PRIVATE json) +# --------------------------------------------------------------------------- +# 2g''') Pure slot_map library — NO REAPER, NO SWELL. The L7 gap-preserving +# display-position carrier for one bank (extracted from bank_book, Q-W1 +# T4-05): sample id -> slot, gap-preserving append/remove/reorder/reconcile, +# JSON round-trip. Mirror of bank_model; wrapped (not merged) by bank_book. +# --------------------------------------------------------------------------- +add_library(slot_map STATIC src/core/model/slot_map.cpp) +target_include_directories(slot_map PUBLIC src) +target_link_libraries(slot_map PRIVATE json) + # --------------------------------------------------------------------------- # 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart # (Phase B1): an ordered registry of banks (pool seeded as bank-zero + named @@ -257,9 +267,10 @@ target_link_libraries(tail_control PRIVATE json) # sample between banks, JSON round-trip + legacy-bank_index→pool migration. # Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched). # --------------------------------------------------------------------------- -add_library(bank_book STATIC src/core/model/bank_book.cpp src/core/model/slot_map.cpp) +add_library(bank_book STATIC src/core/model/bank_book.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) +target_link_libraries(bank_book PUBLIC slot_map) target_link_libraries(bank_book PRIVATE json) # --------------------------------------------------------------------------- @@ -404,7 +415,7 @@ target_include_directories(drag_out PUBLIC src) # parallel byte writer — so the cross-artifact contract cannot drift; links # sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and # NEITHER SDK. The class-ID string derives from the FROZEN UID macros -# (src/shell/instrument/reasampler_uid.h, SDK-free), channel-selected via the generated +# (src/core/wire/reasampler_uid.h, SDK-free), channel-selected via the generated # version header — hence the generated include dir. The round-trip test parses # the container and decodes back through the instrument's own reader. Mirror of # assignment_request. @@ -645,6 +656,10 @@ add_executable(bank_book_tests tests/test_bank_book.cpp) target_link_libraries(bank_book_tests PRIVATE bank_book) add_test(NAME bank_book_tests COMMAND bank_book_tests) +add_executable(slot_map_tests tests/test_slot_map.cpp) +target_link_libraries(slot_map_tests PRIVATE slot_map json) +add_test(NAME slot_map_tests COMMAND slot_map_tests) + add_executable(wav_trim_tests tests/test_wav_trim.cpp) target_link_libraries(wav_trim_tests PRIVATE wav_trim) add_test(NAME wav_trim_tests COMMAND wav_trim_tests) diff --git a/src/actions.h b/src/actions.h index 492f0e5..936b381 100644 --- a/src/actions.h +++ b/src/actions.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // actions — the Design View action family (Phase D4). Registers the bindable // actions that drive the mode workflow and wires them end-to-end: toggle/activate // a mode, tag/untag/show-both the current track selection. Each action mutates the diff --git a/src/bank_panel.h b/src/bank_panel.h index 0139852..1d5d52b 100644 --- a/src/bank_panel.h +++ b/src/bank_panel.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns // a SWELL dialog docked via DockWindowAddEx, and paints the current project's // bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts diff --git a/src/core/audio/peaks.cpp b/src/core/audio/peaks.cpp index 3c4f03a..1d79625 100644 --- a/src/core/audio/peaks.cpp +++ b/src/core/audio/peaks.cpp @@ -123,4 +123,4 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, return kNoFrameAboveThreshold; } -} // namespace reasampler::audio \ No newline at end of file +} // namespace reasampler::audio diff --git a/src/core/audio/peaks.h b/src/core/audio/peaks.h index f1e1ee9..6f3d5b6 100644 --- a/src/core/audio/peaks.h +++ b/src/core/audio/peaks.h @@ -119,4 +119,4 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, std::size_t frameCount, AudioSample linearThreshold); -} // namespace reasampler::audio \ No newline at end of file +} // namespace reasampler::audio diff --git a/src/core/capture/batch_capture.cpp b/src/core/capture/batch_capture.cpp index 1d85add..3f5ff1d 100644 --- a/src/core/capture/batch_capture.cpp +++ b/src/core/capture/batch_capture.cpp @@ -73,4 +73,4 @@ std::string BatchOutcome::summaryLine(const std::string& noun) const { return line; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/batch_capture.h b/src/core/capture/batch_capture.h index 9ad5084..e1353a7 100644 --- a/src/core/capture/batch_capture.h +++ b/src/core/capture/batch_capture.h @@ -97,4 +97,4 @@ private: std::vector results_; }; -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index f7dc629..175f40a 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -284,4 +284,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, return ProjectTransition::NoOp; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 8c92268..1cb9f6c 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -212,4 +212,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& currentGuid, const std::string& currentPath); -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/insert_plan.cpp b/src/core/capture/insert_plan.cpp index d9590de..b58d56c 100644 --- a/src/core/capture/insert_plan.cpp +++ b/src/core/capture/insert_plan.cpp @@ -46,4 +46,4 @@ int computeInsertMode(const InsertOptions& opts) { return mode; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/insert_plan.h b/src/core/capture/insert_plan.h index a3b9770..2bb45e9 100644 --- a/src/core/capture/insert_plan.h +++ b/src/core/capture/insert_plan.h @@ -72,4 +72,4 @@ int computeInsertMode(const InsertOptions& opts); // any computed mode (the "no silent time-stretch" invariant, made checkable). inline constexpr int kStretchToTimeSelBit = 4; -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/realtime_record.cpp b/src/core/capture/realtime_record.cpp index 8a42296..9a754c4 100644 --- a/src/core/capture/realtime_record.cpp +++ b/src/core/capture/realtime_record.cpp @@ -124,4 +124,4 @@ bool isTerminalPhase(RecordPhase phase) { return phase == RecordPhase::Done || phase == RecordPhase::Failed; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/realtime_record.h b/src/core/capture/realtime_record.h index 3ab269b..76e551a 100644 --- a/src/core/capture/realtime_record.h +++ b/src/core/capture/realtime_record.h @@ -235,4 +235,4 @@ bool isStopRequested(RecordPhase phase); // Only Done and Failed are terminal; Recording and Finalizing are live. bool isTerminalPhase(RecordPhase phase); -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index d73ff32..a83c5ca 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -221,4 +221,4 @@ const std::vector& captureActionTable() { return table; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 75f8e30..4c88323 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -262,4 +262,4 @@ struct CaptureActionDef { // capture applies is read from the docked-panel setting, not baked into the row. const std::vector& captureActionTable(); -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/tail_control.cpp b/src/core/capture/tail_control.cpp index 1ee105d..01b9ad8 100644 --- a/src/core/capture/tail_control.cpp +++ b/src/core/capture/tail_control.cpp @@ -128,4 +128,4 @@ std::optional deserializeTailSetting(const std::string& blob) { return out; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/tail_control.h b/src/core/capture/tail_control.h index 2ea3855..9430dba 100644 --- a/src/core/capture/tail_control.h +++ b/src/core/capture/tail_control.h @@ -67,4 +67,4 @@ std::string tailToggleLabel(const TailSetting& setting); std::string serializeTailSetting(const TailSetting& setting); std::optional deserializeTailSetting(const std::string& json); -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/wav_trim.cpp b/src/core/capture/wav_trim.cpp index 5c4d89b..3fbe945 100644 --- a/src/core/capture/wav_trim.cpp +++ b/src/core/capture/wav_trim.cpp @@ -157,4 +157,4 @@ WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) return plan; } -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/capture/wav_trim.h b/src/core/capture/wav_trim.h index f47eea2..497790d 100644 --- a/src/core/capture/wav_trim.h +++ b/src/core/capture/wav_trim.h @@ -100,4 +100,4 @@ struct WavTruncatePlan { // truncate the file to newFileByteLength. WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); -} // namespace reasampler::capture \ No newline at end of file +} // namespace reasampler::capture diff --git a/src/core/instrument/engine/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp index 4fac41a..02957dc 100644 --- a/src/core/instrument/engine/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -48,4 +48,4 @@ void formatMasterGainLabel(double norm, char* buf, std::size_t len) { std::snprintf(buf, len, "%+.1fdB", db); } -} // namespace reasampler::instrument::engine \ No newline at end of file +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/master_gain.h b/src/core/instrument/engine/master_gain.h index dce0d67..42e2b3b 100644 --- a/src/core/instrument/engine/master_gain.h +++ b/src/core/instrument/engine/master_gain.h @@ -55,4 +55,4 @@ double masterGainNormFromLinear(double linear); // including the terminator. Pure. void formatMasterGainLabel(double norm, char* buf, std::size_t len); -} // namespace reasampler::instrument::engine \ No newline at end of file +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 96df91f..6bcdae3 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -431,4 +431,4 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) return static_cast(out); } -} // namespace reasampler::instrument::engine \ No newline at end of file +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index d866826..2a73b8d 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -227,4 +227,4 @@ private: // live fade by that rate. }; -} // namespace reasampler::instrument::engine \ No newline at end of file +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp index 868073a..50af883 100644 --- a/src/core/instrument/engine/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -269,4 +269,4 @@ bool VelocityCurve::equals(const VelocityCurve& other, double eps) const { return true; } -} // namespace reasampler::instrument::engine \ No newline at end of file +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h index 5d1bb27..a2de2dc 100644 --- a/src/core/instrument/engine/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -168,4 +168,4 @@ private: std::vector points_; }; -} // namespace reasampler::instrument::engine \ No newline at end of file +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/map/bank_sync.cpp b/src/core/instrument/map/bank_sync.cpp index b88eea7..10532e9 100644 --- a/src/core/instrument/map/bank_sync.cpp +++ b/src/core/instrument/map/bank_sync.cpp @@ -60,4 +60,4 @@ AssignConsumeDecision consumeDecision(const std::optional& re return d; } -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/bank_sync.h b/src/core/instrument/map/bank_sync.h index acaf1ad..856bfe5 100644 --- a/src/core/instrument/map/bank_sync.h +++ b/src/core/instrument/map/bank_sync.h @@ -104,4 +104,4 @@ AssignConsumeDecision consumeDecision(const std::optional& re std::int64_t lastConsumed, bool resolves, bool isFocusedTarget); -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/bridge_marshal.cpp b/src/core/instrument/map/bridge_marshal.cpp index 3414c34..2163b55 100644 --- a/src/core/instrument/map/bridge_marshal.cpp +++ b/src/core/instrument/map/bridge_marshal.cpp @@ -13,4 +13,4 @@ std::optional decodeGetProjExtState(int apiReturn, return buffer; } -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index 3e432dd..9e918f4 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -34,4 +34,4 @@ namespace reasampler::instrument::map { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/note_entry.cpp b/src/core/instrument/map/note_entry.cpp index 3e936e3..3a3e2a1 100644 --- a/src/core/instrument/map/note_entry.cpp +++ b/src/core/instrument/map/note_entry.cpp @@ -110,4 +110,4 @@ std::optional parseNoteEntry(const std::string& text) { return parseNoteName(s); } -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/note_entry.h b/src/core/instrument/map/note_entry.h index 07dbaf0..b1e909d 100644 --- a/src/core/instrument/map/note_entry.h +++ b/src/core/instrument/map/note_entry.h @@ -30,4 +30,4 @@ namespace reasampler::instrument::map { // into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. std::optional parseNoteEntry(const std::string& text); -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/trigger_seam.cpp b/src/core/instrument/map/trigger_seam.cpp index 3bcc81b..812fb3b 100644 --- a/src/core/instrument/map/trigger_seam.cpp +++ b/src/core/instrument/map/trigger_seam.cpp @@ -24,4 +24,4 @@ std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) return static_cast(fadeFraction * static_cast(playLength) + 0.5); } -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/trigger_seam.h b/src/core/instrument/map/trigger_seam.h index 483bb38..6539589 100644 --- a/src/core/instrument/map/trigger_seam.h +++ b/src/core/instrument/map/trigger_seam.h @@ -48,4 +48,4 @@ double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); // Rounds to nearest integer frame. Returns 0 when playLength == 0. std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); -} // namespace reasampler::instrument::map \ No newline at end of file +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/ui/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp index d643f1a..3a7f9ad 100644 --- a/src/core/instrument/ui/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -155,4 +155,4 @@ std::vector filterNameIndices(const std::vector& names, return out; } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/browser_scroll.h b/src/core/instrument/ui/browser_scroll.h index f04e88c..641d409 100644 --- a/src/core/instrument/ui/browser_scroll.h +++ b/src/core/instrument/ui/browser_scroll.h @@ -104,4 +104,4 @@ bool nameMatchesQuery(const std::string& name, const std::string& query); std::vector filterNameIndices(const std::vector& names, const std::string& query); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/capture_browser.cpp b/src/core/instrument/ui/capture_browser.cpp index 969b540..5ed9517 100644 --- a/src/core/instrument/ui/capture_browser.cpp +++ b/src/core/instrument/ui/capture_browser.cpp @@ -93,4 +93,4 @@ int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { return -1; } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/capture_browser.h b/src/core/instrument/ui/capture_browser.h index a6ea38e..73ecbe7 100644 --- a/src/core/instrument/ui/capture_browser.h +++ b/src/core/instrument/ui/capture_browser.h @@ -89,4 +89,4 @@ Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); // tab strip. Pure. int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/curve_popup.cpp b/src/core/instrument/ui/curve_popup.cpp index c01b1b0..29451e1 100644 --- a/src/core/instrument/ui/curve_popup.cpp +++ b/src/core/instrument/ui/curve_popup.cpp @@ -38,4 +38,4 @@ bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) { return !contains(layout.sheet, x, y); } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/curve_popup.h b/src/core/instrument/ui/curve_popup.h index 4abbf63..3213ec2 100644 --- a/src/core/instrument/ui/curve_popup.h +++ b/src/core/instrument/ui/curve_popup.h @@ -45,4 +45,4 @@ CurvePopupLayout computeCurvePopup(int w, int h); // The shell additionally gates on "no drag in flight" (spec). Pure. bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp index cce57f7..90ccfe5 100644 --- a/src/core/instrument/ui/editor_geometry.cpp +++ b/src/core/instrument/ui/editor_geometry.cpp @@ -159,4 +159,4 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { return contains(layout.addZoneButton, x, y); } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.h b/src/core/instrument/ui/editor_geometry.h index 50fdaee..a0c7ff9 100644 --- a/src/core/instrument/ui/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -139,4 +139,4 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int // True if (x, y) lands on the "Add Zone" button. Pure. bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/embed_strip.cpp b/src/core/instrument/ui/embed_strip.cpp index 3f1a02e..7b8d827 100644 --- a/src/core/instrument/ui/embed_strip.cpp +++ b/src/core/instrument/ui/embed_strip.cpp @@ -83,4 +83,4 @@ Rect levelFillRect(const EmbedLayout& layout, double level) { return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom()); } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/embed_strip.h b/src/core/instrument/ui/embed_strip.h index df32b24..1c28193 100644 --- a/src/core/instrument/ui/embed_strip.h +++ b/src/core/instrument/ui/embed_strip.h @@ -73,4 +73,4 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount // (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. Rect levelFillRect(const EmbedLayout& layout, double level); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp index 68ab989..9b050f1 100644 --- a/src/core/instrument/ui/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -177,4 +177,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect return out; } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h index 808667d..7d7699c 100644 --- a/src/core/instrument/ui/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -105,4 +105,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect double totalSeconds, const EnvClampBounds& bounds, int dxPixels, int dyPixels); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp index 2876f51..a4bf59d 100644 --- a/src/core/instrument/ui/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -55,7 +55,6 @@ int levelToY(const Rect& area, double level) { namespace { - EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) { EnvVertex v; v.node = node; @@ -172,4 +171,4 @@ std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& : triggerPolyline(env, area, totalSeconds); } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index ee839cf..0d06945 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -226,4 +226,4 @@ int timeToX(const Rect& area, double totalSeconds, double t); // and the node hit-test share. int levelToY(const Rect& area, double level); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/keyboard_strip.cpp b/src/core/instrument/ui/keyboard_strip.cpp index f6795bb..7b64af4 100644 --- a/src/core/instrument/ui/keyboard_strip.cpp +++ b/src/core/instrument/ui/keyboard_strip.cpp @@ -146,4 +146,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { return clampNote(startNote + shift); } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/keyboard_strip.h b/src/core/instrument/ui/keyboard_strip.h index 2a6902b..2f843a3 100644 --- a/src/core/instrument/ui/keyboard_strip.h +++ b/src/core/instrument/ui/keyboard_strip.h @@ -128,4 +128,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); // pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types. bool isNaturalKey(int note); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index 06be9b4..6eae276 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -153,4 +153,4 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { return {}; } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 5ffa3fc..2d07b70 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -130,4 +130,4 @@ struct DeckHit { // Pure — the shell's routing entry point. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_slider.cpp b/src/core/instrument/ui/param_slider.cpp index bc2b663..6aa17cc 100644 --- a/src/core/instrument/ui/param_slider.cpp +++ b/src/core/instrument/ui/param_slider.cpp @@ -99,7 +99,6 @@ double normDeg(double deg) { return deg; } - } // namespace KnobGeometry computeKnob(const Rect& cell) { @@ -157,4 +156,4 @@ int controlAtPoint(const std::vector& rows, int x, int y) { return -1; } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_slider.h b/src/core/instrument/ui/param_slider.h index 1cddbf7..0720388 100644 --- a/src/core/instrument/ui/param_slider.h +++ b/src/core/instrument/ui/param_slider.h @@ -177,4 +177,4 @@ double knobDragValue(double startValue, int dyPixels, // toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits. int controlAtPoint(const std::vector& rows, int x, int y); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index 0d566d6..fc004b9 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -96,4 +96,4 @@ std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, return t; // no sign change in the whole buffer -> keep the raw (clamped) target } -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index 34e6948..171b39a 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -82,4 +82,4 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t target); -} // namespace reasampler::instrument::ui \ No newline at end of file +} // namespace reasampler::instrument::ui diff --git a/src/core/model/bank_model.cpp b/src/core/model/bank_model.cpp index 27c8ae8..dee8731 100644 --- a/src/core/model/bank_model.cpp +++ b/src/core/model/bank_model.cpp @@ -458,4 +458,4 @@ std::optional BankModel::deserialize(const std::string& blob) { return idx; } -} // namespace reasampler::model \ No newline at end of file +} // namespace reasampler::model diff --git a/src/core/model/bank_model.h b/src/core/model/bank_model.h index 01cf8af..c7612bc 100644 --- a/src/core/model/bank_model.h +++ b/src/core/model/bank_model.h @@ -214,4 +214,4 @@ private: std::vector samples_; // insertion order preserved }; -} // namespace reasampler::model \ No newline at end of file +} // namespace reasampler::model diff --git a/src/core/model/owned_manifest.cpp b/src/core/model/owned_manifest.cpp index 2dd68af..e2ae900 100644 --- a/src/core/model/owned_manifest.cpp +++ b/src/core/model/owned_manifest.cpp @@ -110,4 +110,4 @@ std::optional OwnedFileManifest::deserialize(const std::strin return m; } -} // namespace reasampler::model \ No newline at end of file +} // namespace reasampler::model diff --git a/src/core/model/owned_manifest.h b/src/core/model/owned_manifest.h index 629f67e..9135b75 100644 --- a/src/core/model/owned_manifest.h +++ b/src/core/model/owned_manifest.h @@ -88,4 +88,4 @@ private: std::vector paths_; // insertion order; deduplicated }; -} // namespace reasampler::model \ No newline at end of file +} // namespace reasampler::model diff --git a/src/core/model/provenance.cpp b/src/core/model/provenance.cpp index cd91e7b..329b66a 100644 --- a/src/core/model/provenance.cpp +++ b/src/core/model/provenance.cpp @@ -159,4 +159,4 @@ std::optional detectParent( return parent; } -} // namespace reasampler::model \ No newline at end of file +} // namespace reasampler::model diff --git a/src/core/model/provenance.h b/src/core/model/provenance.h index 1dd2ca0..cd6255d 100644 --- a/src/core/model/provenance.h +++ b/src/core/model/provenance.h @@ -146,4 +146,4 @@ std::optional detectParent( const std::vector& sourceItemFiles, const std::vector& bankFiles); -} // namespace reasampler::model \ No newline at end of file +} // namespace reasampler::model diff --git a/src/core/reclaim/prune_reconcile.cpp b/src/core/reclaim/prune_reconcile.cpp index a9b009b..464c6b9 100644 --- a/src/core/reclaim/prune_reconcile.cpp +++ b/src/core/reclaim/prune_reconcile.cpp @@ -83,4 +83,4 @@ std::vector pruneDeletePlan(const std::vector& confirm return plan; } -} // namespace reasampler::reclaim \ No newline at end of file +} // namespace reasampler::reclaim diff --git a/src/core/reclaim/prune_reconcile.h b/src/core/reclaim/prune_reconcile.h index 5d3809d..e744827 100644 --- a/src/core/reclaim/prune_reconcile.h +++ b/src/core/reclaim/prune_reconcile.h @@ -175,4 +175,4 @@ PruneReport buildPruneReport(const std::vector& orphans, std::vector pruneDeletePlan(const std::vector& confirmed, const std::vector& freshOrphans); -} // namespace reasampler::reclaim \ No newline at end of file +} // namespace reasampler::reclaim diff --git a/src/core/ui/action_bar.cpp b/src/core/ui/action_bar.cpp index 65f197d..36da30b 100644 --- a/src/core/ui/action_bar.cpp +++ b/src/core/ui/action_bar.cpp @@ -151,4 +151,4 @@ int hitTestActionBar(int px, int py, const ActionBarRect& bar, return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/action_bar.h b/src/core/ui/action_bar.h index 286d928..bb754c2 100644 --- a/src/core/ui/action_bar.h +++ b/src/core/ui/action_bar.h @@ -148,4 +148,4 @@ std::vector computeBarSlots(const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/bank_grid.cpp b/src/core/ui/bank_grid.cpp index 371296b..2522527 100644 --- a/src/core/ui/bank_grid.cpp +++ b/src/core/ui/bank_grid.cpp @@ -224,4 +224,4 @@ float compressAmplitudeForDisplay(float linear) { return linear < 0.0f ? -clamped : clamped; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/bank_grid.h b/src/core/ui/bank_grid.h index c744208..6c4dd00 100644 --- a/src/core/ui/bank_grid.h +++ b/src/core/ui/bank_grid.h @@ -175,4 +175,4 @@ constexpr float kDisplayFloorDb = -60.0f; // (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f. float compressAmplitudeForDisplay(float linear); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/card_drag.cpp b/src/core/ui/card_drag.cpp index 6289d64..6f14fd9 100644 --- a/src/core/ui/card_drag.cpp +++ b/src/core/ui/card_drag.cpp @@ -93,4 +93,4 @@ int hitTestSlot(int px, int py, const std::vector& rects) { return -1; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/card_drag.h b/src/core/ui/card_drag.h index 7ce7a9d..e64ce05 100644 --- a/src/core/ui/card_drag.h +++ b/src/core/ui/card_drag.h @@ -144,4 +144,4 @@ std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, // callers reason in model slots. int hitTestSlot(int px, int py, const std::vector& rects); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/card_meta.cpp b/src/core/ui/card_meta.cpp index a52c4e1..8987dbf 100644 --- a/src/core/ui/card_meta.cpp +++ b/src/core/ui/card_meta.cpp @@ -61,4 +61,4 @@ std::string formatSecondsMs(double lengthSeconds) { return buf; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/card_meta.h b/src/core/ui/card_meta.h index 22cec45..a561cd3 100644 --- a/src/core/ui/card_meta.h +++ b/src/core/ui/card_meta.h @@ -52,4 +52,4 @@ std::string formatBarsBeats(const MusicalLength& m); // * negative length is clamped to "0.000" (a length is never negative; defensive). std::string formatSecondsMs(double lengthSeconds); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/component_geometry.cpp b/src/core/ui/component_geometry.cpp index 6d13d75..3e9d521 100644 --- a/src/core/ui/component_geometry.cpp +++ b/src/core/ui/component_geometry.cpp @@ -105,4 +105,4 @@ int waveformColumnCount(const KitBox& box) { return w > 0 ? w : 0; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/component_geometry.h b/src/core/ui/component_geometry.h index af0419b..7455c47 100644 --- a/src/core/ui/component_geometry.h +++ b/src/core/ui/component_geometry.h @@ -126,4 +126,4 @@ int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCou // identical whether bins == columns or bins == k*columns) and wastes memory and CPU. int waveformColumnCount(const KitBox& box); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/drag_out.cpp b/src/core/ui/drag_out.cpp index 54018a7..06a10e3 100644 --- a/src/core/ui/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -51,4 +51,4 @@ PathList assemblePathList(const std::vector& resolved) { return out; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/drag_out.h b/src/core/ui/drag_out.h index 919284b..a24dd2c 100644 --- a/src/core/ui/drag_out.h +++ b/src/core/ui/drag_out.h @@ -129,4 +129,4 @@ struct PathList { // case-insensitive dedup on Windows — the pure layer does not guess a platform rule). PathList assemblePathList(const std::vector& resolved); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.cpp b/src/core/ui/footer_bar.cpp index ca8145a..e4b683f 100644 --- a/src/core/ui/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -66,4 +66,4 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) { return FooterHit::None; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index 8f4e15e..4d1e58f 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -103,4 +103,4 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& // mode_switch over the toggle box), then the Tail hit; this returns which region was struck. FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/mode_enable.cpp b/src/core/ui/mode_enable.cpp index fc03d7b..0f31bd8 100644 --- a/src/core/ui/mode_enable.cpp +++ b/src/core/ui/mode_enable.cpp @@ -18,4 +18,4 @@ bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { return activeModeId != targetId; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/mode_enable.h b/src/core/ui/mode_enable.h index be0be30..0132f5e 100644 --- a/src/core/ui/mode_enable.h +++ b/src/core/ui/mode_enable.h @@ -36,4 +36,4 @@ enum class TagTarget { // disable an action the user can still reach), so a future added mode never dead-locks the bar. bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/overflow_menu.cpp b/src/core/ui/overflow_menu.cpp index 71c0446..e6dd9b6 100644 --- a/src/core/ui/overflow_menu.cpp +++ b/src/core/ui/overflow_menu.cpp @@ -39,4 +39,4 @@ bool hitTestMenuButton(int px, int py, const MenuButtonRect& button) { py >= button.y && py < button.y + button.height; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/overflow_menu.h b/src/core/ui/overflow_menu.h index 64bcfcb..31972ee 100644 --- a/src/core/ui/overflow_menu.h +++ b/src/core/ui/overflow_menu.h @@ -65,4 +65,4 @@ MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& s // hit-test agree on the same pixels. An empty button never claims a point (always false). bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/prune_button.cpp b/src/core/ui/prune_button.cpp index 6ac2777..71db3a2 100644 --- a/src/core/ui/prune_button.cpp +++ b/src/core/ui/prune_button.cpp @@ -36,4 +36,4 @@ bool hitTestPruneButton(int px, int py, const ButtonRect& button) { py >= button.y && py < button.y + button.height; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/prune_button.h b/src/core/ui/prune_button.h index f482f01..fbd5b3a 100644 --- a/src/core/ui/prune_button.h +++ b/src/core/ui/prune_button.h @@ -79,4 +79,4 @@ ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& s // so a suppressed button cannot be accidentally clicked. bool hitTestPruneButton(int px, int py, const ButtonRect& button); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/tab_strip.cpp b/src/core/ui/tab_strip.cpp index f3b48d0..c0d4db7 100644 --- a/src/core/ui/tab_strip.cpp +++ b/src/core/ui/tab_strip.cpp @@ -109,4 +109,4 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, return miss; // track dead space (no tab under the point) } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/tab_strip.h b/src/core/ui/tab_strip.h index c58809c..4f05ee9 100644 --- a/src/core/ui/tab_strip.h +++ b/src/core/ui/tab_strip.h @@ -124,4 +124,4 @@ struct TabHit { TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/theme.cpp b/src/core/ui/theme.cpp index d947d5c..e80ee62 100644 --- a/src/core/ui/theme.cpp +++ b/src/core/ui/theme.cpp @@ -190,4 +190,4 @@ double textFloor(TextClass cls) { return cls == TextClass::Body ? 4.5 : 3.0; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/theme.h b/src/core/ui/theme.h index 4e47916..0d6f5ba 100644 --- a/src/core/ui/theme.h +++ b/src/core/ui/theme.h @@ -115,4 +115,4 @@ double contrastRatio(const KitColor& a, const KitColor& b); // pair the kit actually draws. double textFloor(TextClass cls); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/tooltip.cpp b/src/core/ui/tooltip.cpp index d47848e..5f09110 100644 --- a/src/core/ui/tooltip.cpp +++ b/src/core/ui/tooltip.cpp @@ -50,4 +50,4 @@ TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, return box; } -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/ui/tooltip.h b/src/core/ui/tooltip.h index 74c5f5b..1682597 100644 --- a/src/core/ui/tooltip.h +++ b/src/core/ui/tooltip.h @@ -59,4 +59,4 @@ TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, int textW, int textH, int clientW, int clientH, const TooltipSpec& spec); -} // namespace reasampler::ui \ No newline at end of file +} // namespace reasampler::ui diff --git a/src/core/util/file_bytes.cpp b/src/core/util/file_bytes.cpp index 3a7b879..b66608c 100644 --- a/src/core/util/file_bytes.cpp +++ b/src/core/util/file_bytes.cpp @@ -18,4 +18,4 @@ std::vector readFileBytes(const std::string& path) { return bytes; } -} // namespace reasampler::util \ No newline at end of file +} // namespace reasampler::util diff --git a/src/core/util/file_bytes.h b/src/core/util/file_bytes.h index 16fe9d4..5214850 100644 --- a/src/core/util/file_bytes.h +++ b/src/core/util/file_bytes.h @@ -16,4 +16,4 @@ namespace reasampler::util { // "nothing to work with" branch. std::vector readFileBytes(const std::string& path); -} // namespace reasampler::util \ No newline at end of file +} // namespace reasampler::util diff --git a/src/core/version/app_version.cpp b/src/core/version/app_version.cpp index c84d212..568f02d 100644 --- a/src/core/version/app_version.cpp +++ b/src/core/version/app_version.cpp @@ -166,4 +166,4 @@ WritingVersion classifyWritingVersion(const std::string& rawStamp) { return wv; } -} // namespace reasampler::version \ No newline at end of file +} // namespace reasampler::version diff --git a/src/core/version/app_version.h b/src/core/version/app_version.h index ea04abc..603dcf1 100644 --- a/src/core/version/app_version.h +++ b/src/core/version/app_version.h @@ -199,4 +199,4 @@ struct WritingVersion { // with the raw ext-state read and never has to reason about the cases itself. WritingVersion classifyWritingVersion(const std::string& rawStamp); -} // namespace reasampler::version \ No newline at end of file +} // namespace reasampler::version diff --git a/src/core/view/guid_diff.cpp b/src/core/view/guid_diff.cpp index be4a2af..303f94e 100644 --- a/src/core/view/guid_diff.cpp +++ b/src/core/view/guid_diff.cpp @@ -41,4 +41,4 @@ void GuidBaseline::reset() { primed_ = false; // next observe() re-baselines (first-poll guard re-armed) } -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/guid_diff.h b/src/core/view/guid_diff.h index 2c7bfc3..5026ec2 100644 --- a/src/core/view/guid_diff.h +++ b/src/core/view/guid_diff.h @@ -59,4 +59,4 @@ private: bool primed_ = false; // false ⇒ next observe() sets the baseline }; -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/lane_keys.cpp b/src/core/view/lane_keys.cpp index b241ff7..b003925 100644 --- a/src/core/view/lane_keys.cpp +++ b/src/core/view/lane_keys.cpp @@ -48,4 +48,4 @@ bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName) { return !hasManagedPrefix(laneName); } -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/lane_keys.h b/src/core/view/lane_keys.h index 6799704..36cb133 100644 --- a/src/core/view/lane_keys.h +++ b/src/core/view/lane_keys.h @@ -82,4 +82,4 @@ std::optional modeIdFromLaneName(const std::string& laneName); // inputs (I_FREEMODE result, P_LANENAME string) and never re-derives this logic. bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName); -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/mode_switch.cpp b/src/core/view/mode_switch.cpp index 881af49..6bc8c57 100644 --- a/src/core/view/mode_switch.cpp +++ b/src/core/view/mode_switch.cpp @@ -61,4 +61,4 @@ int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount) { return -1; } -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/mode_switch.h b/src/core/view/mode_switch.h index 07c9ce7..44b8250 100644 --- a/src/core/view/mode_switch.h +++ b/src/core/view/mode_switch.h @@ -46,4 +46,4 @@ std::vector computeSegmentRects(const HeaderRect& header, // the panel drew there. int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount); -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/view_tree.cpp b/src/core/view/view_tree.cpp index 65b239d..6356ec0 100644 --- a/src/core/view/view_tree.cpp +++ b/src/core/view/view_tree.cpp @@ -38,4 +38,4 @@ FolderTree buildFolderTree(const std::vector& entries) { return tree; } -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/view/view_tree.h b/src/core/view/view_tree.h index ad63a91..7d2d968 100644 --- a/src/core/view/view_tree.h +++ b/src/core/view/view_tree.h @@ -30,4 +30,4 @@ struct TrackFolderEntry { // is clamped to empty) so a corrupt/stale project can never fault the shell. FolderTree buildFolderTree(const std::vector& entries); -} // namespace reasampler::view \ No newline at end of file +} // namespace reasampler::view diff --git a/src/core/wire/assignment_request.cpp b/src/core/wire/assignment_request.cpp index 5ca645d..ab05c53 100644 --- a/src/core/wire/assignment_request.cpp +++ b/src/core/wire/assignment_request.cpp @@ -40,4 +40,4 @@ std::optional decodeAssignmentRequest(const std::string& wire return req; } -} // namespace reasampler::wire \ No newline at end of file +} // namespace reasampler::wire diff --git a/src/core/wire/assignment_request.h b/src/core/wire/assignment_request.h index 5847405..12e469a 100644 --- a/src/core/wire/assignment_request.h +++ b/src/core/wire/assignment_request.h @@ -86,4 +86,4 @@ std::string encodeAssignmentRequest(const AssignmentRequest& req); // silently, never crashing or selecting a nonexistent entry. std::optional decodeAssignmentRequest(const std::string& wire); -} // namespace reasampler::wire \ No newline at end of file +} // namespace reasampler::wire diff --git a/src/core/wire/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp index 36dfd9d..e475830 100644 --- a/src/core/wire/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -1,12 +1,12 @@ // instrument_drop — pure implementation. See instrument_drop.h. // NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer and -// the SDK-free UID macros (vst/reasampler_uid.h). +// the SDK-free UID macros (core/wire/reasampler_uid.h). #include "core/wire/instrument_drop.h" #include -#include "shell/instrument/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID +#include "core/wire/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID #include "core/instrument/map/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) namespace reasampler::wire { @@ -106,4 +106,4 @@ bool infoNamesFxHotspot(const std::string& info) { return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx"); } -} // namespace reasampler::wire \ No newline at end of file +} // namespace reasampler::wire diff --git a/src/core/wire/instrument_drop.h b/src/core/wire/instrument_drop.h index d44de6d..25fec92 100644 --- a/src/core/wire/instrument_drop.h +++ b/src/core/wire/instrument_drop.h @@ -3,7 +3,7 @@ // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK, // NO vendor/ includes. Standard library only (+ the pure sample_map it reuses and the -// SDK-free UID macros in vst/reasampler_uid.h). Unit-tested outside the DAW — the same +// SDK-free UID macros in core/wire/reasampler_uid.h). Unit-tested outside the DAW — the same // "small pure builder + round-trip proof" pattern as assignment_request / provenance. // // -- What it is (the S17 seam, extension side) -------------------------------- @@ -43,7 +43,7 @@ namespace reasampler::wire { // header carries (public.sdk vstpresetfile: "ASCII-encoded FUID"). On both COM-compatible // (Windows GUID byte order) and plain layouts, FUID::toString reduces to the four // INLINE_UID uint32 words printed "%08X" in order, so this derivation is platform-stable. -// Sourced from the FROZEN macros in vst/reasampler_uid.h (the same constants the factory +// Sourced from the FROZEN macros in core/wire/reasampler_uid.h (the same constants the factory // registers), channel-selected by the one REASAMPLER_CHANNEL_IS_BETA bit — a beta extension // writes presets only the beta VST class accepts, preserving the S18 pairing invariant. std::string vstClassIdHex(); @@ -111,4 +111,4 @@ bool infoNamesFxHotspot(const std::string& info); // its setState expects. Not called by the shell (which uses the .vstpreset image). std::vector instrumentDropStateBytes(const std::string& sampleId); -} // namespace reasampler::wire \ No newline at end of file +} // namespace reasampler::wire diff --git a/src/shell/instrument/reasampler_uid.h b/src/core/wire/reasampler_uid.h similarity index 100% rename from src/shell/instrument/reasampler_uid.h rename to src/core/wire/reasampler_uid.h diff --git a/src/core/wire/sample_usage.cpp b/src/core/wire/sample_usage.cpp index 3f45708..0489650 100644 --- a/src/core/wire/sample_usage.cpp +++ b/src/core/wire/sample_usage.cpp @@ -230,4 +230,4 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper return !nameUpper.empty() && up.find(nameUpper) != std::string::npos; } -} // namespace reasampler::wire \ No newline at end of file +} // namespace reasampler::wire diff --git a/src/core/wire/sample_usage.h b/src/core/wire/sample_usage.h index 1bef5eb..8553bf0 100644 --- a/src/core/wire/sample_usage.h +++ b/src/core/wire/sample_usage.h @@ -250,4 +250,4 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper // ASCII-only uppercase (shared by the matcher and the shell's needle preparation). std::string toUpperAscii(const std::string& s); -} // namespace reasampler::wire \ No newline at end of file +} // namespace reasampler::wire diff --git a/src/ext_keys.h b/src/ext_keys.h index 7fcb56d..71d6953 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -1,4 +1,3 @@ -#include "core/namespaces.h" #pragma once // ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state // namespace + key names, shared by the extension (writer, via persist.h) and the @@ -27,7 +26,7 @@ namespace reasampler { // because the value is fixed by the channel bit at build time. This is the wire-contract // reconciliation between S4 (shared ext_keys) and V4 (channel-isolated namespace): without // it a beta instrument would read the stable namespace and see empty state. -inline const char* kProjExtNamespace() { return extStateNamespace().c_str(); } +inline const char* kProjExtNamespace() { return version::extStateNamespace().c_str(); } // The multi-bank key: the whole serialized BankBook (pool + named banks). This is // the key the VST3 instrument reads to see the live bank (read-only, S4). persist.h diff --git a/src/ingest.h b/src/ingest.h index 3fcd40d..6c74d57 100644 --- a/src/ingest.h +++ b/src/ingest.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // ingest — the S8 "ingest through the bank" shell (EXTENSION side). // // Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads, diff --git a/src/persist.h b/src/persist.h index 1c9cbb2..74a3d37 100644 --- a/src/persist.h +++ b/src/persist.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // persist — the REAPER-facing bridge between the in-memory BankModel and project // ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths). // diff --git a/src/shell/actions/drag_out_win.h b/src/shell/actions/drag_out_win.h index 4382c82..1a5781b 100644 --- a/src/shell/actions/drag_out_win.h +++ b/src/shell/actions/drag_out_win.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // drag_out_win — the OS/COM initiation half of native OS drag-out (Milestone 11). The pure // gesture-boundary decision and path-list assembly live in drag_out.*; THIS is the platform // shell that hands a resolved, existing-file path list to the operating system's drag-drop diff --git a/src/shell/actions/instrument_drop_win.h b/src/shell/actions/instrument_drop_win.h index 161b900..f9aa016 100644 --- a/src/shell/actions/instrument_drop_win.h +++ b/src/shell/actions/instrument_drop_win.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture // decision lives in drag_out (DragGesture::InstrumentDrop) and the pure payload construction // in instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index 48190f8..fd9a54d 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). // // This header declares the capture *seam* the later milestones fill: diff --git a/src/shell/capture/insert.h b/src/shell/capture/insert.h index 5c6ea59..18aa2c7 100644 --- a/src/shell/capture/insert.h +++ b/src/shell/capture/insert.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // insert — placement of bank samples into the arrange (M6). REAPER-facing shell: // it reads the bank_panel's current selection, resolves each selected sample's // file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped diff --git a/src/shell/capture/item_read.h b/src/shell/capture/item_read.h index 1aec24a..4c8da44 100644 --- a/src/shell/capture/item_read.h +++ b/src/shell/capture/item_read.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // item_read — the ONE place a MediaItem* is read for its canonical GUID string and for // the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and // bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair diff --git a/src/shell/capture/provenance_shell.h b/src/shell/capture/provenance_shell.h index 594002e..1a1dab5 100644 --- a/src/shell/capture/provenance_shell.h +++ b/src/shell/capture/provenance_shell.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place. // // The PURE provenance module (provenance.h) owns the fingerprint encoding, the diff --git a/src/shell/capture/track_guid.h b/src/shell/capture/track_guid.h index 0bbadd5..ec28cae 100644 --- a/src/shell/capture/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // string used as a membership-index key. Both the Design View shell (view.cpp) and // the actions layer (actions.cpp) key membership on this exact string, so the key diff --git a/src/shell/instrument/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h index 004c54a..ef7183b 100644 --- a/src/shell/instrument/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell: // resolves REAPER API functions by name over the host context and reads the live // "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure). @@ -17,6 +16,7 @@ // reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike. #pragma once +#include "core/namespaces.h" #include #include diff --git a/src/shell/instrument/reasampler_embed.h b/src/shell/instrument/reasampler_embed.h index bf4f52f..23e9b56 100644 --- a/src/shell/instrument/reasampler_embed.h +++ b/src/shell/instrument/reasampler_embed.h @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's // IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h + // reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in @@ -32,6 +31,7 @@ // REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor. #pragma once +#include "core/namespaces.h" #include #include diff --git a/src/shell/instrument/reasampler_vst.h b/src/shell/instrument/reasampler_vst.h index 7e252ea..5e99b76 100644 --- a/src/shell/instrument/reasampler_vst.h +++ b/src/shell/instrument/reasampler_vst.h @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument // (Phase S). One place for the plugin's class UID, name, vendor, and version so the // processor, factory, and editor agree. @@ -19,10 +18,11 @@ // binary UID identity — the string identity lives in the pure module). #pragma once +#include "core/namespaces.h" #include "pluginterfaces/base/funknown.h" -#include "shell/instrument/reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values) +#include "core/wire/reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values) namespace reasampler::vst { diff --git a/src/shell/panel/draw_kit.h b/src/shell/panel/draw_kit.h index 594f3e0..c8696d0 100644 --- a/src/shell/panel/draw_kit.h +++ b/src/shell/panel/draw_kit.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // draw_kit — the LICE-facing SHELL half of the shared drawing kit (Phase L, L1). This is // the ONE source of drawing for the whole system: every surface (bank_panel now; the VST // editor + embed strip at L3) fills, buttons, rows, sliders, waveforms, and — above all — diff --git a/src/shell/persist/usage_scan.h b/src/shell/persist/usage_scan.h index 21db288..c1bfe0b 100644 --- a/src/shell/persist/usage_scan.h +++ b/src/shell/persist/usage_scan.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for // the pure core, the fail-safe folds, and the full design note). At prune-scan time it // answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 1bf8fa6..4ad8c58 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,5 +1,5 @@ -#include "core/namespaces.h" #pragma once +#include "core/namespaces.h" // view — the REAPER-facing shell of the Design View feature (Phase D2). It is the // mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/ // membership/snapshot state and emits the toggle plan; this shell reads the live diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 03938d8..18a26ea 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000 // capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the // host's IPlugView seat and routes host paint/mouse into the pure geometry modules @@ -23,6 +22,7 @@ // to create/destroy the child window and onSize to resize it. #pragma once +#include "core/namespaces.h" #include #include diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 29b3b6c..e339048 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the // pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio // output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the @@ -26,6 +25,7 @@ // single atomic pointer swap. See the LoadedInstrument handoff below. #pragma once +#include "core/namespaces.h" #include #include diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp index f72b69f..c25ddaf 100644 --- a/tests/test_bank_book.cpp +++ b/tests/test_bank_book.cpp @@ -42,6 +42,29 @@ static Sample sampleWith(const std::string& seed) { return sampleWith(seed, "has // --------------------------------------------------------------------------- +// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a +// small fixture (a freshly-seeded book: pool only, one sample), not just +// self-consistent re-serialization — a format drift that both writer and +// reader agree on would slip past the round-trip tests but not this. The +// format is frozen as-shipped; the literal below is the captured current +// output. +static void testSerializeGoldenLiteral() { + BankBook book; + CHECK(book.pool().index.add(sampleWith("g1")) == AddResult::Added); + CHECK(book.serialize() == + "{\"version\":1,\"activeBank\":\"pool\",\"banks\":[{\"id\":\"pool\"," + "\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"version\":1," + "\"samples\":[{\"id\":\"id-g1\",\"displayName\":\"sample g1\"," + "\"relativePath\":\"bank/g1.wav\",\"sourceMode\":0,\"sourceRange\":{" + "\"startSeconds\":0,\"endSeconds\":0,\"startPpq\":0,\"endPpq\":0}," + "\"trackGuids\":[],\"wetDry\":1,\"channelCount\":2,\"sampleRate\":48000," + "\"lengthSeconds\":0,\"lengthBeats\":0,\"captureTempo\":0," + "\"captureTimeSigNum\":0,\"captureTimeSigDenom\":0,\"key\":null," + "\"rootNote\":null,\"loop\":null,\"levels\":{\"peakDb\":0,\"rmsDb\":0," + "\"lufs\":0},\"clipped\":false,\"tier\":0,\"contentHash\":\"hash-g1\"," + "\"provenance\":null,\"createdTimestamp\":1753080000}]},\"slots\":[]}]}"); +} + static void testPoolSeededAndDefaults() { BankBook book; // Pool present as bank-zero with fixed id + name + ordinal 0. @@ -790,123 +813,13 @@ static void testUpdateSampleInPlace() { // =========================================================================== // L7 — SlotMap (gap-preserving display positions) + BankBook ordering/reorder/replace // =========================================================================== - -// --- SlotMap unit behaviour -------------------------------------------------- - -static void testSlotMapDenseAppend() { - SlotMap m; - m.append("a"); - m.append("b"); - m.append("c"); - CHECK(m.slotOf("a") == 0); - CHECK(m.slotOf("b") == 1); - CHECK(m.slotOf("c") == 2); - CHECK(m.maxSlot() == 2); - CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); - CHECK(m.idAt(1) == "b"); - CHECK(m.slotOf("nope") == -1); -} - -static void testSlotMapRemoveLeavesGap() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - CHECK(m.remove("b")); // slot 1 now EMPTY (no re-pack) - CHECK(m.slotOf("a") == 0); - CHECK(m.slotOf("c") == 2); // c did NOT shift down - CHECK(m.idAt(1).empty()); // gap preserved - CHECK((m.orderedIds() == std::vector{"a", "c"})); - CHECK(!m.remove("b")); // already gone -} - -static void testSlotMapAppendAfterGapGoesToFrontier() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.remove("a"); // slot 0 empty - m.append("d"); // append goes AFTER last occupied (2) -> 3 - CHECK(m.slotOf("d") == 3); // did NOT fill the slot-0 gap - CHECK(m.idAt(0).empty()); -} - -static void testSlotMapReorderIntoEmpty() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.remove("b"); // slot 1 empty - CHECK(m.reorder("c", 1)); // c -> empty slot 1; its slot 2 empties - CHECK(m.slotOf("c") == 1); - CHECK(m.idAt(2).empty()); - CHECK(m.slotOf("a") == 0); // untouched -} - -static void testSlotMapReorderOntoOccupiedInsertsAndShifts() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); m.append("d"); // 0,1,2,3 - CHECK(m.reorder("d", 1)); // d onto occupied slot 1 -> insert-before, shift b,c up - CHECK(m.slotOf("a") == 0); // before the target: unchanged - CHECK(m.slotOf("d") == 1); // took the target slot - CHECK(m.slotOf("b") == 2); // shifted +1 - CHECK(m.slotOf("c") == 3); // shifted +1 - CHECK((m.orderedIds() == std::vector{"a", "d", "b", "c"})); -} - -static void testSlotMapReorderPreservesInteriorGapAboveTarget() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.remove("b"); // gap at 1: a@0, c@2 - m.append("d"); // d@3 - CHECK(m.reorder("d", 0)); // d onto occupied slot 0 -> a shifts to 1, c shifts to 3 - CHECK(m.slotOf("d") == 0); - CHECK(m.slotOf("a") == 1); // shifted from 0 -> 1 - CHECK(m.slotOf("c") == 3); // shifted from 2 -> 3 (gap at 2 preserved as a +1 of its own) - CHECK(m.idAt(2).empty()); // interior gap above the target survives -} - -static void testSlotMapReorderUnmappedIsNoOp() { - SlotMap m; - m.append("a"); - CHECK(!m.reorder("ghost", 0)); // not mapped -> false, no mutation - CHECK(m.slotOf("a") == 0); -} - -static void testSlotMapNegativeTargetClampsToZero() { - SlotMap m; - m.append("a"); m.append("b"); // 0,1 - CHECK(m.reorder("b", -3)); // clamp to 0 -> insert-before a - CHECK(m.slotOf("b") == 0); - CHECK(m.slotOf("a") == 1); -} - -static void testSlotMapResetDenseSkipsDupesAndEmpties() { - SlotMap m; - m.resetDense({"a", "", "b", "a", "c"}); // "" and the second "a" dropped - CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); - CHECK(m.slotOf("a") == 0); - CHECK(m.slotOf("c") == 2); -} - -static void testSlotMapReconcileDropsStaleAppendsNew() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.reconcile({"a", "c", "d"}); // b left the index (drop), d is new (append) - CHECK(m.slotOf("a") == 0); // kept at its slot - CHECK(m.slotOf("c") == 2); // kept at its slot (gap where b was) - CHECK(m.slotOf("b") == -1); // stale marker dropped - CHECK(m.slotOf("d") == 3); // appended after the frontier - CHECK(m.idAt(1).empty()); // b's slot stays empty -} - -static void testSlotMapEqualityAndFromEntries() { - SlotMap a; - a.append("x"); a.append("y"); - SlotMap b = SlotMap::fromEntries({{"x", 0}, {"y", 1}}); - CHECK(a == b); - // Defensive repair: duplicate id (first wins), slot conflict (later dropped), - // empty id / negative slot dropped. - SlotMap c = SlotMap::fromEntries({{"x", 0}, {"x", 5}, {"y", 0}, {"", 9}, {"z", -1}, {"w", 2}}); - CHECK(c.slotOf("x") == 0); // first x wins - CHECK(c.slotOf("y") == -1); // slot 0 already taken -> dropped - CHECK(c.slotOf("w") == 2); // valid - CHECK(c.slotOf("z") == -1); // negative slot dropped -} +// +// Pure SlotMap-only unit behaviour (add/remove/query, reorder gap-preservation, +// resetDense/reconcile, equality/fromEntries, serialize golden literal + round +// trip) now lives in test_slot_map.cpp (Q-W1 follow-up), extracted per the house +// every-pure-module-has-a-_tests rule. This file keeps the BankBook-level +// integration coverage below: reorderSample / reconcileSlots / JSON round-trip +// WITH a full book. // --- BankBook L7: JSON round-trip WITH positions ----------------------------- @@ -1081,6 +994,7 @@ static void testReplaceSampleInPoolPassesGuard() { } int main() { + testSerializeGoldenLiteral(); testPoolSeededAndDefaults(); testPoolPrivileges(); testCreateRenameReorder(); @@ -1117,18 +1031,8 @@ int main() { testRemoveAllBanksLatentScope(); testUpdateSampleInPlace(); - // L7 — SlotMap + ordering/reorder/replace + slot round-trip/migration. - testSlotMapDenseAppend(); - testSlotMapRemoveLeavesGap(); - testSlotMapAppendAfterGapGoesToFrontier(); - testSlotMapReorderIntoEmpty(); - testSlotMapReorderOntoOccupiedInsertsAndShifts(); - testSlotMapReorderPreservesInteriorGapAboveTarget(); - testSlotMapReorderUnmappedIsNoOp(); - testSlotMapNegativeTargetClampsToZero(); - testSlotMapResetDenseSkipsDupesAndEmpties(); - testSlotMapReconcileDropsStaleAppendsNew(); - testSlotMapEqualityAndFromEntries(); + // L7 — BankBook ordering/reorder/replace + slot round-trip/migration. + // (Pure SlotMap-only unit behaviour lives in slot_map_tests.) testBankBookSlotsRoundTrip(); testMigrationDefaultsToInsertionOrderDense(); testOrderedSampleIdsReconcilesLazily(); diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 8111e91..299aef0 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -104,6 +104,30 @@ static void testFullFieldRoundTrip() { } } +// Golden byte-literal (Q-W1 T?-05 follow-up): pins the EXACT serialized bytes for +// a small fixture, not just self-consistent re-serialization — a format drift +// that round-trips losslessly (e.g. a renamed key both writer and reader agree +// on) would slip past testFullFieldRoundTrip but not this. The format is frozen +// as-shipped; the literal below is the captured current output. +static void testSerializeGoldenLiteral() { + BankModel idx; + Sample s; + s.id = "g1"; + s.relativePath = "bank/g1.wav"; + s.contentHash = "hash-g1"; + CHECK(idx.add(s) == AddResult::Added); + CHECK(idx.serialize() == + "{\"version\":1,\"samples\":[{\"id\":\"g1\",\"displayName\":\"\"," + "\"relativePath\":\"bank/g1.wav\",\"sourceMode\":0,\"sourceRange\":{" + "\"startSeconds\":0,\"endSeconds\":0,\"startPpq\":0,\"endPpq\":0}," + "\"trackGuids\":[],\"wetDry\":1,\"channelCount\":0,\"sampleRate\":0," + "\"lengthSeconds\":0,\"lengthBeats\":0,\"captureTempo\":0," + "\"captureTimeSigNum\":0,\"captureTimeSigDenom\":0,\"key\":null," + "\"rootNote\":null,\"loop\":null,\"levels\":{\"peakDb\":0,\"rmsDb\":0," + "\"lufs\":0},\"clipped\":false,\"tier\":0,\"contentHash\":\"hash-g1\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); +} + static void testDedupByHash() { BankModel idx; Sample a = fullSample("x"); @@ -551,6 +575,7 @@ static void testSeamFieldsAdditiveInvariant() { int main() { testFullFieldRoundTrip(); + testSerializeGoldenLiteral(); testDedupByHash(); testTierFilterAndMove(); testRelativePathInvariant(); diff --git a/tests/test_owned_manifest.cpp b/tests/test_owned_manifest.cpp index e5f27f8..d850004 100644 --- a/tests/test_owned_manifest.cpp +++ b/tests/test_owned_manifest.cpp @@ -36,6 +36,18 @@ static void testEmptyManifest() { CHECK(back->empty()); } +// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a +// small fixture (two paths), not just self-consistent re-serialization — a +// format drift that both writer and reader agree on would slip past the +// round-trip tests but not this. The format is frozen as-shipped; the literal +// below is the captured current output. +static void testSerializeGoldenLiteral() { + OwnedFileManifest m; + m.add("reasampler_bank/a.wav"); + m.add("reasampler_bank/b.wav"); + CHECK(m.serialize() == "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}"); +} + // --- add / contains / order -------------------------------------------------- static void testAddAndContains() { @@ -159,6 +171,7 @@ static void testMalformedParse() { } int main() { + testSerializeGoldenLiteral(); testEmptyManifest(); testAddAndContains(); testDedupRepeatedAdds(); diff --git a/tests/test_slot_map.cpp b/tests/test_slot_map.cpp new file mode 100644 index 0000000..72fce65 --- /dev/null +++ b/tests/test_slot_map.cpp @@ -0,0 +1,228 @@ +// Standalone tests for reasampler::model::SlotMap — no REAPER, no test framework. +// SlotMap is the L7 gap-preserving display-position carrier for one bank, extracted +// from bank_book (Q-W1, T4-05). These are the pure SlotMap-only assertions that +// previously lived inline in test_bank_book.cpp (the L7 "SlotMap unit behaviour" +// block); test_bank_book.cpp keeps its BankBook-level integration coverage +// (reorderSample / reconcileSlots / JSON round-trip WITH a full book), this file +// owns the module's own contract: add/remove/query, reorder gap-preservation, +// resetDense/reconcile, equality/fromEntries, and the serialize wire shape. + +#include "../src/core/model/slot_map.h" + +#include +#include +#include +#include + +#include "../src/core/json/json.h" + +using namespace reasampler::model; +namespace json = reasampler::json; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- SlotMap unit behaviour -------------------------------------------------- + +static void testSlotMapDenseAppend() { + SlotMap m; + m.append("a"); + m.append("b"); + m.append("c"); + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("b") == 1); + CHECK(m.slotOf("c") == 2); + CHECK(m.maxSlot() == 2); + CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); + CHECK(m.idAt(1) == "b"); + CHECK(m.slotOf("nope") == -1); +} + +static void testSlotMapRemoveLeavesGap() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + CHECK(m.remove("b")); // slot 1 now EMPTY (no re-pack) + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("c") == 2); // c did NOT shift down + CHECK(m.idAt(1).empty()); // gap preserved + CHECK((m.orderedIds() == std::vector{"a", "c"})); + CHECK(!m.remove("b")); // already gone +} + +static void testSlotMapAppendAfterGapGoesToFrontier() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("a"); // slot 0 empty + m.append("d"); // append goes AFTER last occupied (2) -> 3 + CHECK(m.slotOf("d") == 3); // did NOT fill the slot-0 gap + CHECK(m.idAt(0).empty()); +} + +static void testSlotMapReorderIntoEmpty() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("b"); // slot 1 empty + CHECK(m.reorder("c", 1)); // c -> empty slot 1; its slot 2 empties + CHECK(m.slotOf("c") == 1); + CHECK(m.idAt(2).empty()); + CHECK(m.slotOf("a") == 0); // untouched +} + +static void testSlotMapReorderOntoOccupiedInsertsAndShifts() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); m.append("d"); // 0,1,2,3 + CHECK(m.reorder("d", 1)); // d onto occupied slot 1 -> insert-before, shift b,c up + CHECK(m.slotOf("a") == 0); // before the target: unchanged + CHECK(m.slotOf("d") == 1); // took the target slot + CHECK(m.slotOf("b") == 2); // shifted +1 + CHECK(m.slotOf("c") == 3); // shifted +1 + CHECK((m.orderedIds() == std::vector{"a", "d", "b", "c"})); +} + +static void testSlotMapReorderPreservesInteriorGapAboveTarget() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("b"); // gap at 1: a@0, c@2 + m.append("d"); // d@3 + CHECK(m.reorder("d", 0)); // d onto occupied slot 0 -> a shifts to 1, c shifts to 3 + CHECK(m.slotOf("d") == 0); + CHECK(m.slotOf("a") == 1); // shifted from 0 -> 1 + CHECK(m.slotOf("c") == 3); // shifted from 2 -> 3 (gap at 2 preserved as a +1 of its own) + CHECK(m.idAt(2).empty()); // interior gap above the target survives +} + +static void testSlotMapReorderUnmappedIsNoOp() { + SlotMap m; + m.append("a"); + CHECK(!m.reorder("ghost", 0)); // not mapped -> false, no mutation + CHECK(m.slotOf("a") == 0); +} + +static void testSlotMapNegativeTargetClampsToZero() { + SlotMap m; + m.append("a"); m.append("b"); // 0,1 + CHECK(m.reorder("b", -3)); // clamp to 0 -> insert-before a + CHECK(m.slotOf("b") == 0); + CHECK(m.slotOf("a") == 1); +} + +static void testSlotMapResetDenseSkipsDupesAndEmpties() { + SlotMap m; + m.resetDense({"a", "", "b", "a", "c"}); // "" and the second "a" dropped + CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("c") == 2); +} + +static void testSlotMapReconcileDropsStaleAppendsNew() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.reconcile({"a", "c", "d"}); // b left the index (drop), d is new (append) + CHECK(m.slotOf("a") == 0); // kept at its slot + CHECK(m.slotOf("c") == 2); // kept at its slot (gap where b was) + CHECK(m.slotOf("b") == -1); // stale marker dropped + CHECK(m.slotOf("d") == 3); // appended after the frontier + CHECK(m.idAt(1).empty()); // b's slot stays empty +} + +static void testSlotMapEqualityAndFromEntries() { + SlotMap a; + a.append("x"); a.append("y"); + SlotMap b = SlotMap::fromEntries({{"x", 0}, {"y", 1}}); + CHECK(a == b); + // Defensive repair: duplicate id (first wins), slot conflict (later dropped), + // empty id / negative slot dropped. + SlotMap c = SlotMap::fromEntries({{"x", 0}, {"x", 5}, {"y", 0}, {"", 9}, {"z", -1}, {"w", 2}}); + CHECK(c.slotOf("x") == 0); // first x wins + CHECK(c.slotOf("y") == -1); // slot 0 already taken -> dropped + CHECK(c.slotOf("w") == 2); // valid + CHECK(c.slotOf("z") == -1); // negative slot dropped +} + +// --- serialize: golden byte-literal + round-trip ----------------------------- + +// Pins the exact wire shape (an array of {"id":..,"slot":..} objects, ascending +// slot, no whitespace) so a future format drift is caught here rather than only +// as a downstream bank_book diff. Mirrors the pre-extraction bank_book writer +// byte-for-byte (core/json emit helpers are shared, not reimplemented). +static void testSlotMapSerializeGoldenLiteral() { + SlotMap empty; + CHECK(empty.serialize() == "[]"); + + SlotMap m; + m.append("a"); + m.append("b"); + CHECK(m.serialize() == "[{\"id\":\"a\",\"slot\":0},{\"id\":\"b\",\"slot\":1}]"); +} + +// A local mirror of bank_book's private parseSlots (the "slots" array grammar): +// [{id, slot}, ...]. slot_map.cpp itself only emits — JSON parsing is a consumer +// concern (see slot_map.h) — so the round-trip proof below parses the emitted +// text back into pairs the same way bank_book does, then rebuilds via +// SlotMap::fromEntries and checks equality against the original. +static bool parseSlotsArray(json::Reader& r, std::vector>& out) { + out.clear(); + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty array + do { + if (!r.consume('{')) return false; + std::string id; + int slot = 0; + bool haveId = false, haveSlot = false; + do { + std::string k; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } + else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } + else { if (!r.skipValue()) return false; } + } while (r.consume(',')); + if (!r.consume('}')) return false; + if (!haveId || !haveSlot) return false; + out.emplace_back(std::move(id), slot); + } while (r.consume(',')); + return r.consume(']'); +} + +static void testSlotMapSerializeRoundTrip() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); + m.remove("b"); // leave a gap: a@0, c@2 + m.append("d"); // d@3 + + const std::string blob = m.serialize(); + json::Reader r(blob); + std::vector> pairs; + CHECK(parseSlotsArray(r, pairs)); + + SlotMap round = SlotMap::fromEntries(pairs); + CHECK(round == m); + CHECK(round.slotOf("a") == 0); + CHECK(round.idAt(1).empty()); // gap survives the round trip + CHECK(round.slotOf("c") == 2); + CHECK(round.slotOf("d") == 3); +} + +int main() { + testSlotMapDenseAppend(); + testSlotMapRemoveLeavesGap(); + testSlotMapAppendAfterGapGoesToFrontier(); + testSlotMapReorderIntoEmpty(); + testSlotMapReorderOntoOccupiedInsertsAndShifts(); + testSlotMapReorderPreservesInteriorGapAboveTarget(); + testSlotMapReorderUnmappedIsNoOp(); + testSlotMapNegativeTargetClampsToZero(); + testSlotMapResetDenseSkipsDupesAndEmpties(); + testSlotMapReconcileDropsStaleAppendsNew(); + testSlotMapEqualityAndFromEntries(); + testSlotMapSerializeGoldenLiteral(); + testSlotMapSerializeRoundTrip(); + + if (g_fail == 0) { + std::printf("slot_map_tests: all passed\n"); + return 0; + } + std::printf("slot_map_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 34bb1b3..bb57611 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -56,6 +56,20 @@ static int flagValue(const TrackPlan& p, Flag f) { return -999; // sentinel: flag absent } +// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for the +// default-seeded model (Arrange + Design, no membership), not just self- +// consistent re-serialization — a format drift that both writer and reader +// agree on would slip past the round-trip tests but not this. The format is +// frozen as-shipped; the literal below is the captured current output. +static void testSerializeGoldenLiteral() { + ViewModeModel vm; + CHECK(vm.serialize() == + "{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," + "\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\"," + "\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[]," + "\"snapshots\":[],\"lanes\":[]}"); +} + // -- 1. N-mode proven -------------------------------------------------------- static void testNModeRegistryAndMembership() { @@ -1787,6 +1801,7 @@ static void testLaneMalformedJson() { } int main() { + testSerializeGoldenLiteral(); testNModeRegistryAndMembership(); testParentDerivationMultiMode(); testParentOwnMembershipVisibility(); From 9dd3440b750e3cd86f74dd069eb05f05a1c92a11 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 09:50:37 -0400 Subject: [PATCH 21/40] =?UTF-8?q?docs(phase-q):=20record=20Q-W0=20close=20?= =?UTF-8?q?+=20Q-W1=20landing=20=E2=80=94=20points=20moved=20to=20COMPLETE?= =?UTF-8?q?D.md;=20skipped=20riders=20and=20bank=5Fbook=20nameKey=20residu?= =?UTF-8?q?al=20preserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMPLETED.md | 72 ++++++++++++++++++++++++++++++++++++++ PLAN.md | 97 ++++++++++++++++------------------------------------ 2 files changed, 101 insertions(+), 68 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index dbfba43..613de11 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -2883,3 +2883,75 @@ now covered by the same mechanism. - [x] `reasampler_processor`: `reloadInstrument` decodes from `SampleRefs_` (bank-free); `SampleRefs` table added to `ComponentState` v10; heal timer + poll-to-play removed; preview summing removed; `PreviewCard` member removed. - [x] `sample_map`: `SampleRefs` type + `SampleRefEntry` struct; `refreshRefsFromBank`, `retainRefs`, `resolvePerformanceFromRefs` (decodes directly from refs, no bank blob required); `kComponentStateVersion` bumped to 10; pre-v10 lift to empty refs. - [x] `ComponentState` v10 serialization round-trip: new `sampleRefs` field; pre-v10 blobs migrate on load. + +--- + +## Q-W0 fix-now remediations — closes Q-W0 (2026-07-28) + +> **Merged to `phase-q` 2026-07-28 (commit `546927e`).** Closes the Q-W0 sub-gate — Q-W0 +> (pre-restructure functional + DSP quality audit) is now fully closed. Part of the phase-q +> integration that later reached 60/60 green with Q-W1. + +**Goal:** Land the six Daniel-approved fix-now remediations from the Q-W0 audit triage, plus +seven review riders surfaced during their review, before any structural Q-wave begins. +**Verify:** Each remediation lands with its module's CTest target green; the audible DSP fixes +carry a stated before/after listening check. + +- [x] **T1-01** — linked-lag stereo SOLA + follower self-heal fallback. +- [x] **T1-03** — playable-span prime bound. +- [x] **T1-09** — declick dead-state removal. +- [x] **T2-01a** — provenance wire-cursor hardening backport. +- [x] **T3-01** — rate-derived gain ramp. +- [x] **T3-03** — rate-derived fade ceiling. +- [x] Seven additional review riders landed in the same merge (not individually itemized in + this entry). + +**Notes/decisions:** +- Landing these six fix-nows (plus the review riders) closes Q-W0 entirely — audit, triage, + sign-off, and remediation are all complete — and opens Q-W1. + +--- + +## Q-W1 — safe opener: `core/json` extraction + directory/namespace layout (2026-07-29) + +> **Merged to `phase-q` 2026-07-29. Integrated suite 60/60 green.** First structural wave of +> Phase Q; unblocked by the Q-W0 fix-now remediations closing 2026-07-28. + +**Goal:** The zero-god-module-risk opener. Extract a pure `core/json` module (parser + +serializer) and delete the five hand-rolled JSON decoders — the four `Parser`s in `bank_model` +/ `bank_book` / `view_mode_model` / `owned_manifest` plus `tail_control`'s fifth decoder; impose +the settled `core/`/`shell/`/`app/` directory layout + sub-namespaces on the clean pure libs, +clean shells, and clean VST pure libs — pure relocation, no logic change. +**Verify:** CTest green at every commit (60/60). The five duplicate JSON decoders are gone, +replaced by one `core/json` consumed by all five former consumers; round-trip serialization is +byte-identical to before. Every relocated clean module compiles and its test executable passes +unmoved. No REAPER type crosses into any `core/` file. + +- [x] `core/json` extracted (`json::Reader`/`json::Writer`); `bank_model`, `bank_book`, + `view_mode_model`, `owned_manifest`, and `tail_control` rewired onto it; the five hand-rolled + JSON decoders deleted. +- [x] Wire-`Cursor` family collapsed into `core/wire` — one hardened survivor codec. +- [x] Shared `readFileBytes` pure helper added; linked by both artifacts. +- [x] `slot_map` extracted from `bank_book`, with its own `slot_map_tests`. +- [x] ~50 clean modules relocated into `core/{model,view,capture,audio,ui,reclaim,version,json, + util,wire}/`, `core/instrument/{engine,map,ui}/`, `shell/{capture,panel,view,persist,actions, + instrument}/`; `main.cpp` moved to `app/`. Sub-namespaces applied to every relocated clean + module. +- [x] Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases — the + XYWH-vs-LTRB fork retired, `footer_bar.h`'s "NAME NOTE" collision workaround gone. +- [x] `clamp01` deduplicated. +- [x] Naming riders: survivor JSON parser minted as `json::Reader`/`json::Writer`; `BankIndex` + renamed to `BankModel` (verified by `bank_model_tests`). +- [x] `reasampler_uid.h` relocated to `core/wire/`. +- [x] Interim `core/namespaces.h` shim added for the six not-yet-split god TUs; each downstream + split wave (Q-W2 onward) retires its own includes of it as that module splits. + +**Notes/decisions:** +- **Riders explicitly skipped/deferred:** T4-22 (`hitIndex` hit-test template) — not trivial, + deferred as an opportunistic follow-on once the rect unification is in use downstream; T4-06 + (`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) — + deferred (in scope only if a later wave touches `view.cpp` anyway). +- **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is + identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and + **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this + residual before assuming the split is finished. diff --git a/PLAN.md b/PLAN.md index b907ed6..5624d3c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -315,11 +315,13 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > the reorg is risk-ordered waves (W1 safe opener → W2/W2v–W5 god-module splits → W6 OCP finish). ## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1) -**STATUS (2026-07-28): audit COMPLETE, triage COMPLETE, sign-off COMPLETE.** The findings report -is committed (`docs/product/code-quality-audit.md`; track appendices in -`docs/product/audit-notes/` — T1 DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 -findings). Daniel approved every disposition 2026-07-28. **The Q-W1 sub-gate is satisfied once -the six approved fix-now remediations land** (final point below; in flight on `pq-w0-fixes`). +**STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now +remediations LANDED — Q-W0 is fully closed.** The findings report is committed +(`docs/product/code-quality-audit.md`; track appendices in `docs/product/audit-notes/` — T1 +DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 findings). Daniel approved +every disposition 2026-07-28. The six approved fix-now remediations plus seven review riders +landed 2026-07-28 (merge `546927e`) — see `COMPLETED.md`. **Q-W1 has since landed on top of +this closure** (see `COMPLETED.md`). **Goal:** Before a single structural point moves, perform a **thorough static/functional audit** of the codebase and produce a **written, triaged findings report**. This is the *functional-correctness and algorithm-quality* complement to the grep-verified SOLID/naming audit that already grounds @@ -376,72 +378,31 @@ before/after listening or null check. **The gate to Q-W1 is: triage complete + D begin until this is done; fold any new/reshaped downstream points the audit surfaces into Q-W1..Q-W6 before starting them. **DONE (Daniel, 2026-07-28): all 59 dispositions approved as proposed; the §3 plan reshape and §4 decisions are folded into Q-W1..Q-W6 + Q-W2v below.** -- [ ] **Fix-now remediations (approved 2026-07-28; Q-W0-scoped; in flight on branch - `pq-w0-fixes`):** T1-01 linked-lag stereo splice alignment, T1-03 Preserve prime bound + - immediate `freezeTail()`, T1-09 `declickR_` dead-state removal (rider on the `sampler_core` - edits), T2-01(a) provenance wire-cursor hardening backport, T3-01 gain-ramp seconds, T3-03 - fade-ceiling seconds. Each lands with its module's CTest target green; the audible DSP fixes - with a stated before/after listening check. **Landing these closes Q-W0 and opens Q-W1.** ## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules -**Goal:** The zero-god-module-risk opener. Two moves: (1) extract a pure **`core/json`** module -(parser + serializer) and **delete the five hand-rolled JSON decoders** — the four `Parser`s in -`bank_model` / `bank_book` / `view_mode_model` / `owned_manifest` **plus `tail_control`'s fifth -decoder** (T2-02, Q-W0's undercount fix — the wave's one-JSON-path goal is not met without it) — -the single largest DRY+SRP violation, entirely off the hot paths; (2) impose the settled -`core/`/`shell/`/`app/` directory layout + sub-namespaces -(`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json` + the `instrument` -family) on the **30 clean pure libs + the clean shells that need no splitting + the ~20 clean -VST pure libs** (T4 §1.5, under the settled T4-18 `core/instrument/{engine,map,ui}` + -`shell/instrument/` shape) — pure relocation, no logic change. **Q-W0 additions (SETTLED -2026-07-28):** the shared `readFileBytes` pure helper (T2-03); the wire-`Cursor` collapse into -one shared codec beside `core/json` (T2-01(b)); the rect unification — one **concrete** -`ui::Rect` + `contains()` + per-role aliases, NOT a template (T2-05 ≡ T4-21). Proves the wave -discipline (relocate + encapsulate, CTest-green) before any god-module surgery. CONTEXT.md -§Phase Q (json extraction; directory + namespace map). -**Verify:** CTest green at every commit. The five duplicate JSON decoders are gone, replaced by -one `core/json` consumed by all five consumers; round-trip serialization is byte-identical to before -(no format change — a *structural* dedupe, not a behavior change). Every relocated clean module -compiles and its test executable passes unmoved. `Sample` (model) vs `AudioSample` (audio) vs -unified `Parser` (json) do not collide once sub-namespaced. No REAPER type crosses into any -`core/` file; the CMake pure/shell enforcement still holds. -**Depends on:** the GATE (tree quiescent) **and Q-W0 closed** (audit triaged + Daniel signed off; -any fix-now findings the audit assigned to Q-W1 folded in). First structural wave. -- [ ] Extract `core/json` (pure parser + serializer: parseString/parseInt/parseKey/skipValue + - escape, plus emit helpers); unify under `reasampler::json`; guard the `Parser` name against - cross-lib collision. Off all hot paths — safe to abstract freely. -- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest`, **and - `tail_control` (T2-02)** onto `core/json`; **delete the five duplicate decoders.** Round-trip - output byte-identical (dedupe, not reformat). -- [ ] Add the shared `readFileBytes` pure helper to the `core/` utility home; both artifacts - link it (T2-03). -- [ ] Collapse the length-prefixed wire-`Cursor` family into one shared wire codec beside - `core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` / - `parseBankGeneration` (T2-01(b) — the structural half; the hardening backport lands in Q-W0). -- [ ] Riders on files this wave already opens: `slot_map` extraction from `bank_book` (T4-05); - `view_mode_model` planner split (T4-06 — optional if the wave wants to stay minimal); - `view_lanes` split only if relocation touches `view.cpp` anyway (T4-09). -- [ ] Relocate the 30 clean pure libs into `core/{model,view,capture,audio,ui,reclaim,version, - json}/` **+ the ~20 clean VST pure libs into `core/instrument/{engine,map,ui}/` (T4-18 - SETTLED, Daniel 2026-07-28)**, and the clean shells into - `shell/{capture,panel,view,persist,actions,instrument}/`; move `main.cpp` to `app/`. Update - `CMakeLists.txt` `src/` paths only (no target-graph change). -- [ ] Apply sub-namespaces matching the directories on every relocated *clean* module (the - god-modules re-namespace their own new TUs as they split, W2/W2v–W5). Resolve `Sample`/ - `AudioSample`/`Parser` homes. **This alone resolves the naming *collisions*** (§2b.2): the - shared pure-UI rect types (`FooterRect`/`ButtonRect`/`Selection`/`CellRect`) get one `ui::` - owner — one **concrete** `ui::Rect` + `contains()` + per-role aliases, retiring the - XYWH-vs-LTRB fork and folding in `editor_geometry`'s `Rect` (T2-05 ≡ T4-21 — explicitly NOT a - template); retire the hand-collision "NAME NOTE" in `footer_bar.h`. Riders: `clamp01` dedup - (T4-24); the `hitIndex` hit-test template only as an opportunistic follow-on once the rect - unification lands (T4-22). -- [ ] **Naming riders (Q-8 — SETTLED, Daniel 2026-07-28: both renames):** the survivor JSON - parser is minted as **`json::Reader`/`json::Writer`**; land **`BankIndex`→`BankModel`** here - (mechanical class rename, verified by `bank_model_tests`). No rename on a file this wave isn't - already relocating (Q-7). -- [ ] Confirm CTest green + no hot-path change: `peaks`/audition/realtime-tick untouched by this - wave (pure relocation of clean modules; `peaks` stays a free function). +> **Landed on `phase-q` (2026-07-29). Integrated suite 60/60 green.** `core/json` +> (`json::Reader`/`json::Writer`) extracted; the five hand-rolled JSON decoders (incl. +> `tail_control`'s) deleted; the wire-`Cursor` family collapsed into `core/wire`; the shared +> `readFileBytes` helper added; ~50 clean modules relocated into `core/{model,view,capture, +> audio,ui,reclaim,version,json,util,wire}/`, `core/instrument/{engine,map,ui}/`, +> `shell/{capture,panel,view,persist,actions,instrument}/`, `app/main.cpp`; sub-namespaces +> applied; one concrete `ui::Rect` + aliases (LTRB fork + `footer_bar` NAME NOTE retired); +> `slot_map` extracted from `bank_book`; `clamp01` deduped; `BankIndex`→`BankModel`; +> `reasampler_uid.h` relocated to `core/wire/`. See `COMPLETED.md` for the full narrative. +> +> **Skipped/deferred riders:** T4-22 (`hitIndex` hit-test template) — not trivial, deferred as +> an opportunistic follow-on once the rect unification is in use downstream; T4-06 +> (`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) — +> deferred (in scope only if a later wave touches `view.cpp` anyway). +> +> **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is +> identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and +> **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this +> residual before assuming the split is finished. +> +> An interim `core/namespaces.h` shim covers the six not-yet-split god TUs; each downstream +> split wave (Q-W2 onward) retires its own includes of it as that module splits. ## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) **Goal:** Split the largest extension god-module (8+ responsibilities) along the audit's named From b8e8bb4c75704d7af2e06ebeb092f5cd54739c04 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 10:39:35 -0400 Subject: [PATCH 22/40] =?UTF-8?q?docs(todo-1.0):=20item=2015=20=E2=80=94?= =?UTF-8?q?=20one-click=20in-sampler=20resample=20(fourth=20batch,=202026-?= =?UTF-8?q?07-29);=20five=20questions=20await=20Daniel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO-1.0.md | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 3 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 45410ed..7eddc25 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -3,7 +3,8 @@ Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering (2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day); -item 14 is a third, single-item batch (2026-07-28, later again). +item 14 is a third, single-item batch (2026-07-28, later again); item 15 is +a fourth, single-item batch (2026-07-29). Deliberately specified at the level of product intent, user-visible behavior, and acceptance criteria — **no implementation design, no file/module references**. These were authored while Phase Q was @@ -73,14 +74,28 @@ filter question: **the filter envelope follows the same mode-driven shape** — Gate → AHDSR, Trigger → AHD. Item 8's scope rule now governs all three envelopes uniformly (the general statement lives in item 14; item 2 carries a cross-reference), and item 14's remaining stage-value question now covers the -filter envelope too. **With that, no open question anywhere in the doc awaits -a Daniel decision.** Everything still open across all 14 items is +filter envelope too. **With that, no open question in items 1–14 awaits a +Daniel decision.** Everything still open across all 14 items is verify-or-propose-at-implementation: item 9's loop-point regression verification and its spec details, item 11's does-a-pitch-velocity-curve-already-exist check, and item 14's shared-vs-per-mode stage-value question. Nothing in items 1–14 is blocked on Daniel. +A **fourth batch** (2026-07-29) appends item 15 — a single enhancement Daniel +himself frames as "a pretty radical feature idea": one-click resampling from +inside the ReaSampler 9000. An offline pass through the instrument's own +processing is captured back into the bank, the source capture is replaced (or +a distinct capture added when other references exist), the instance re-points +at the recapture, and the audio parameters reset to default — the +dial → bake → dial-again iteration loop, run without leaving the sampler. +**Item 15 reopens the Daniel-blocked state: five of its open questions await +a Daniel decision** — the multi-zone scope of "the sound," what the offline +pass plays (note, velocity, and the Gate-mode hold/tail policy), the +parameter-reset scope, what counts as "other references," and the +undo/recovery expectation. Its remaining questions are +propose-at-implementation-review; items 1–14 stay fully unblocked. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -99,6 +114,13 @@ AHD definition and item 1's curve treatment — cheapest folded into or immediately after that combined work — and repaints amp-deck/overlay surface that items 10 and 13 later polish; its filter half (the filter envelope following the same mode-driven shape) necessarily lands with or after item 2. +Item 15 (fourth batch) presupposes the processing surface it bakes — +"filtering, pitching, amp all set up nice" is the instrument items 1, 2, 3, +and 14 build — so its spec only becomes fully meaningful once those have +landed, and its Gate-mode render policy touches item 9's loop-sustain; that +is a statement of what the feature operates on, not a schedule decision. Its +bank-side half rides the already-settled capture and prune safety model, not +any queued item. --- @@ -1056,3 +1078,148 @@ follow-up round extends the same rule to item 2's filter envelope. semantics, curve treatment, and overlay mapping match. (This is the observable proxy for the consolidation motive: one staged-envelope design, two consumers.) + +--- + +## 15 — Enhancement: one-click in-sampler resample — bake the dialed sound into the bank and iterate + +**Daniel's ask (verbatim, 2026-07-29).** + +> I would like the ability to somehow resample from directly inside the +> ReaSampler 9000 VST. Once I have the filtering, pitching, amp all set up +> nice, one click should: +> send a trigger or gate through the sampler offline, capture the audio, +> replace the bank source capture with the recapture (or add a new distinct +> one if there are other references), replace that sampler capture with the +> new/corrected bank capture, and reinitialize the reasampler 9000 audio +> parameters to default, effectively allow reiteration over sounds from +> within the sampler. + +**Intent.** Close the sound-design loop from inside the instrument: once the +filtering, pitching, and amp are set up nice, one click bakes that processing +into the audio itself — a new bank capture — and hands the instrument back at +neutral, ready to be dialed again. Daniel frames it as a radical feature, and +it is: it turns the sampler from a player of captures into an iterative +resampling instrument, with the bank as the medium each iteration passes +through. + +**Behavior.** + +- **One gesture, whole chain.** A single click performs the full sequence: + offline pass → capture → bank update (replace or add-distinct) → instance + re-point → parameter reset. The steps are one action from the user's side, + not a wizard. +- **Offline pass through the instrument's own processing.** The audio is + produced by sending a trigger or gate through the sampler **offline** — the + instrument's own voice path, with the filtering, pitching, and amp exactly + as dialed, renders the result. The recapture is of that processed output, + not of the raw source. +- **The recapture is a bank capture like any other.** It lands in the bank — + project-relative, indexed, browsable from any surface that browses the + bank — and is governed by the same safety rules as every file the system + itself creates. +- **Replace, or add distinct.** When nothing else references the source + capture, the recapture **replaces** it as the bank entry; when other + references exist, the original entry stays and the recapture is **added as + a new distinct capture**. (What counts as an "other reference" is an open + question below.) +- **Replacement never destroys audio bytes.** "Replace" means the bank entry + now denotes the recapture; the superseded file itself is not deleted by + this action. *(Derived, not a Daniel quote: the settled rule that the + prune action is the system's only file-deletion authority admits no other + reading — resample writes a new file and retires the old one to + reclaimable-by-prune status; it never overwrites or deletes it.)* Until a + prune reclaims it, the pre-bake audio therefore survives on disk — the + iterate loop's built-in recovery floor, whatever the undo question below + settles. +- **The instance re-points.** The sampler's loaded capture is replaced with + the new/corrected bank capture — the instrument now plays the baked sound. +- **Parameters reinitialize to default.** After the swap, the instrument's + audio parameters return to default — destructive to the dialed settings + **by design**: the processing now lives in the recaptured audio, and the + neutral controls are the starting point for the next iteration. Which + parameters "audio parameters" covers is an open question below. +- **No timeline item, ever.** Resampling is a capture act: it writes a file + to the bank and updates the index; nothing is placed in the arrange view. + *(Derived, not a Daniel quote: the tool's load-bearing capture/placement + separation forces this — any framing of the feature that auto-inserts the + recapture into the timeline is invalid.)* + +**Open questions.** + +- **Multi-zone scope — awaits a Daniel decision.** The ask speaks of "the + sound" and "that sampler capture" in the singular. On an instrument with + multiple zones — or zones referencing different captures — what does one + click resample: is the feature restricted to single-capture setups, does + it bake one zone (which?), or is there a whole-keymap story? The + single-capture reading is the natural one, but the multi-zone rule — even + if the rule is an explicit restriction — must be chosen, not assumed. +- **What the offline pass plays — awaits a Daniel decision.** "A trigger or + gate" presumably follows the active playback mode, but the render + performance is otherwise unstated: which note (natural lean: the capture's + root), what velocity (material, because the velocity curves modulate amp — + and, with items 2 and 11, pitch and filter), and for Gate mode a hold + length and tail policy — how long the gate holds (item 9's loop-sustain + makes a held gate indefinite, so *some* bound is required) and how long + the release rings before the capture ends. Trigger mode has a natural + answer (play to completion); Gate does not. +- **Reset scope — awaits a Daniel decision.** Does "reinitialize the audio + parameters to default" mean every parameter, or only those whose effect + the recapture baked in? Concretely undecided either way: zone key ranges, + loop points, the VOICE group, MASTER gain, and the velocity transfer + curves — which reset, which survive. +- **What counts as "other references" — awaits a Daniel decision.** Other + sampler instances holding the capture? Items placed in the arrange from + it? Its membership in more than one bank? The answer decides when + iteration updates the bank entry in place versus forks a distinct + capture — i.e., whether a sound something else still depends on can + disappear from the bank (its file survives regardless; see Behavior). The + prune model already has a settled notion of what protects a file from + deletion; whether that same universe governs replace-vs-add here is the + decision. +- **Undo / recovery — awaits a Daniel decision.** The chain is destructive + to the dialed settings by design. Is any of it undoable — one undo + restoring the bank entry and the instrument's prior parameters — or is + the loop deliberately forward-only, with the superseded file's + survival-until-pruned the only recovery path? The bank side has a settled + one-operation-one-undo convention; the instrument-parameter side has no + precedent to lean on. +- **Extension presence — propose at implementation review.** The instrument + plays self-contained with the extension absent, but the bank is the + extension's surface, and resampling mutates the bank. The natural answer + is that resample requires the extension present and is cleanly + unavailable — not silently lossy — without it; propose the exact behavior + at review. +- **Provenance of the recapture — propose at implementation review.** + Captures carry a reproducibility fingerprint of their capture recipe; a + resample's recipe is the instrument's own settings, not a track's chain. + Whether the recapture records a resample-shaped fingerprint or — the + settled conservative default for ambiguous cases — records nothing is a + propose-at-review call. +- **Naming and lineage — propose at implementation review.** When + add-distinct fires, the new capture needs a display name (derived from + the original?), and the bank some way to read iteration lineage across + repeated bakes; propose at review. + +**Acceptance criteria.** + +- On a dialed-in single-capture instrument, one click yields all of: a + recapture in the bank, the instance holding that recapture, and the audio + parameters at their defaults (per whatever reset scope Daniel settles). +- The bake is audible and faithful: after the click, playing what the + offline pass played (same note, same velocity, same mode) through the + now-neutral controls sounds as the dialed instrument sounded just before + the click — the processing has moved from the controls into the audio. +- Sole-reference case: the bank afterwards shows the recapture where the + source capture's entry was; no other bank entry is disturbed. + Other-references case: the original entry is untouched, a distinct new + entry appears, and every other holder of the original sounds exactly as + before. +- The click deletes no file: the superseded audio file still exists on disk + afterwards, and only a later prune — under the settled orphan rules, only + when nothing references it — can reclaim it. +- The arrange timeline is untouched: no item appears anywhere, on any track. +- Iteration composes: dial → click → dial → click bakes the second pass onto + the first's result, repeatable indefinitely. +- Save/reload: an instance holding a recapture reloads and plays it exactly + like any other loaded capture. From d8651fb7a7d0de32b529d007468ff17299fa2a5c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:03:17 -0400 Subject: [PATCH 23/40] docs(todo-1.0): item 15 answer round; item 16 retires zone mapping Five blocking questions folded; capture-signal popup specced; items 2, 9, 11, 12 carry supersession notes. Sole remaining Daniel-blocker: multi-zone migration. --- TODO-1.0.md | 364 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 294 insertions(+), 70 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 7eddc25..0e5882b 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -4,7 +4,9 @@ Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering (2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day); item 14 is a third, single-item batch (2026-07-28, later again); item 15 is -a fourth, single-item batch (2026-07-29). +a fourth, single-item batch (2026-07-29); item 16 (2026-07-29) is not a new +ask batch — it records the system change mandated by answer 1 of item 15's +answer round (the zone mapping system is retired). Deliberately specified at the level of product intent, user-visible behavior, and acceptance criteria — **no implementation design, no file/module references**. These were authored while Phase Q was @@ -89,13 +91,27 @@ processing is captured back into the bank, the source capture is replaced (or a distinct capture added when other references exist), the instance re-points at the recapture, and the audio parameters reset to default — the dial → bake → dial-again iteration loop, run without leaving the sampler. -**Item 15 reopens the Daniel-blocked state: five of its open questions await -a Daniel decision** — the multi-zone scope of "the sound," what the offline -pass plays (note, velocity, and the Gate-mode hold/tail policy), the +**Item 15 reopened the Daniel-blocked state: five of its open questions +awaited a Daniel decision** — the multi-zone scope of "the sound," what the +offline pass plays (note, velocity, and the Gate-mode hold/tail policy), the parameter-reset scope, what counts as "other references," and the undo/recovery expectation. Its remaining questions are propose-at-implementation-review; items 1–14 stay fully unblocked. +A **fourth-batch answer round** (2026-07-29) answered all five. The answers +are folded into item 15's Behavior (marked *settled by answer round*). +Answer 1 is a **system change, not a scoping rule**: the zone mapping system +is retired — ReaSampler 9000 becomes **one capture = one parameter set** — +recorded in full as **item 16**, which supersedes the doc's per-zone-storage +and Sample/Zone-panel-parity language throughout (items 2, 9, 11, and 12 +carry marked superseded-by/cross-reference notes; Daniel's verbatim asks +stand exactly as written). Answer 2 spawns a new sub-feature inside item 15: +a **capture-signal popup** (note length, start/end offsets in ms and in +beats, velocity, and a preview trigger). **Current Daniel-blocked state: +exactly one question awaits a Daniel decision — item 16's migration of saved +multi-zone instances.** Everything else open across items 1–16 is +verify-or-propose-at-implementation. + Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's filter envelope ("filter to be added"), and item 3 layers on both. They can land in sequence or together, but 1 and 2 are prerequisites for 3's full surface. @@ -120,7 +136,14 @@ and 14 build — so its spec only becomes fully meaningful once those have landed, and its Gate-mode render policy touches item 9's loop-sustain; that is a statement of what the feature operates on, not a schedule decision. Its bank-side half rides the already-settled capture and prune safety model, not -any queued item. +any queued item. Item 16 (zone retirement) is a **prerequisite of meaning** +for item 15 — it is what makes "the sound" singular and dissolves the +multi-zone question — and it touches the surfaces other queued items work +on: items 2, 9, and 11 now store their parameters into the one parameter +set rather than per-zone, and item 12's full-width piano strip is +re-grounded by what the strip means with zones retired. That too is +dependency-of-meaning, not a schedule: the affected items' work is +unchanged in substance; only its storage/parity framing simplifies. --- @@ -293,7 +316,12 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. - **Per-zone storage.** The filter's parameters are **stored per-zone**, alongside the other playback parameters — Sample/Zone panel parity applies, same as the existing per-zone controls (VOICE and MASTER remain the - per-instance exceptions). *(Settled by second follow-up.)* + per-instance exceptions). *(Settled by second follow-up.)* **Superseded by + item 16** (zone retirement): the filter's parameters join the instrument's + **one parameter set** — no per-zone storage, no Sample/Zone parity, no + per-instance exception structure to be an exception to. The follow-up's + substance carries over unchanged: the filter's parameters live with the + other playback parameters. - **Off by default.** The filter **defaults to off** — pre-existing saved instances and freshly loaded captures sound unchanged until the user engages it. *(Settled by second follow-up.)* @@ -332,9 +360,10 @@ AHDSR envelope, and make the knob-deck row read in signal-flow order. - Two simultaneously sounding voices at different envelope phases are filtered independently (per-voice processing is audible, not a shared instance-wide filter). -- Filter parameters follow Sample/Zone panel parity: they appear and edit on - both surfaces, and each zone carries its own filter settings (two zones with - different filter settings audibly differ). +- Filter parameters live in the instrument's one parameter set: they edit in + one place and govern the instrument as a whole. (Original per-zone/parity + form — two surfaces in parity, per-zone divergence — superseded by item 16; + neither is expressible after the zone retirement.) - Velocity and key-tracking modulation of the filter ship with this item and are audible — velocity following the amp-velocity-transfer-curve pattern, key-tracking following the pitch-key-tracking pattern. @@ -757,9 +786,10 @@ playback cycling the loop until note-off, then release. - The regression verification above (removed vs. unexposed). - The crossfade parameter's units and range are unspecified — Daniel call, or a proposed default surfaced at implementation review. -- Storage side: presumably per-zone alongside the other playback parameters - (Sample/Zone panel parity, with VOICE/MASTER the per-instance exceptions) — - confirm. +- Storage side: closed by **item 16** (zone retirement) — the loop + parameters belong to the instrument's one parameter set; there is no + per-zone side left to confirm. (Cross-reference: item 15's settled reset + scope classifies loop points as baked-in, so they reset on a resample.) - Editing surface for loop start/end (waveform markers, knobs, or both) is unspecified; the waveform display is the natural home for range markers, but Daniel has not said. @@ -853,7 +883,11 @@ playback cycling the loop until note-off, then release. because the proposed MASTER placement implied per-instance storage; with the buttons in their own VELOCITY group that doubt is gone, and the project's settled convention — playback parameters are per-zone, VOICE and - MASTER the per-instance exceptions — decides it.)* + MASTER the per-instance exceptions — decides it.)* **Superseded by item + 16** (zone retirement): the curves live in the instrument's **one + parameter set**. The bullet's point survives in simplified form — the + curves store with the other playback parameters; the per-zone framing and + the parity convention it leaned on are gone. - **Bipolar pitch/filter transfer functions.** Pitch and filter velocity transfer functions are **bipolar: y range [−1, 1], default y = 0** — flat at zero, meaning velocity modulation of pitch and filter is **off until the @@ -871,7 +905,8 @@ playback cycling the loop until note-off, then release. introduced by this item — unverifiable here under the no-code-reads constraint; if absent, this item introduces it. (The former second question — amp-curve storage under the MASTER placement — dissolved with the - placement's supersession; storage is settled per-zone above.) + placement's supersession; storage is settled above — per-zone at the time + of that round, now the one parameter set per item 16.) **Acceptance criteria.** @@ -880,8 +915,9 @@ playback cycling the loop until note-off, then release. - The three velocity-curve buttons sit together in a deck group labelled **VELOCITY**, immediately to the left of the VOICE group; no velocity-curve button appears in MASTER, PITCH, or Filter. -- The velocity curves follow Sample/Zone panel parity and persist per-zone - (two zones with different curves audibly differ). +- The velocity curves persist in the instrument's one parameter set and + round-trip save/reload. (Original per-zone/parity form superseded by + item 16; per-zone divergence is no longer expressible.) - Opening the pitch or filter curve shows a bipolar editor ([−1, 1]) defaulted flat at y = 0; played velocities produce no pitch/filter modulation until a curve is drawn, then audibly follow it. @@ -917,7 +953,13 @@ playback cycling the loop until note-off, then release. - **Cross-references.** Item 11 also changes the preview button (glyph); the two compose — the glyph button in its new toolbar position. Item 13's anti-aliasing audit covers the key-pattern rendering if aliasing turns out - to be the cause. + to be the cause. **Item 16** (zone retirement) removes the zone count + label's referent entirely — this item's removal of the label is doubly + settled — and re-grounds what the full-width strip displays: with no zone + bars, its surviving jobs are the root affordance (item 15's answer round + makes root explicitly resample-stable) and these note-name tooltips; + whether any key-range display remains follows item 16's key-range + question. **Acceptance criteria.** @@ -1095,6 +1137,23 @@ follow-up round extends the same rule to item 2's filter envelope. > parameters to default, effectively allow reiteration over sounds from > within the sampler. +**Daniel's answer round (verbatim, 2026-07-29)** — answering, in order, the +five questions this item carried as awaiting a Daniel decision: + +> 1. I don't actually care, or even really understand, our zones. Retire +> the zone mapping system, simplify. I don't expect to use it, +> reasampler 9000 becomes 1 capture = one parameter set +> 2. capture root (so that parameter is not reset by resampling), for the +> gate and velocity questions, we need a popup menu to program note +> length, start/end offsets in ms AND in beats, and velocity for the +> capture signal. The popup shoulud have a preview trigger button to +> allow previewing the note capture as currently programed. +> 3. only what is baked into the sample recapture (contours, filter, master +> gain, etc) +> 4. any usage of that capture that is tied by the provenance/recaptureing +> system +> 5. Undo/Recovery is a plus + **Intent.** Close the sound-design loop from inside the instrument: once the filtering, pitching, and amp are set up nice, one click bakes that processing into the audio itself — a new bank capture — and hands the instrument back at @@ -1121,8 +1180,9 @@ through. - **Replace, or add distinct.** When nothing else references the source capture, the recapture **replaces** it as the bank entry; when other references exist, the original entry stays and the recapture is **added as - a new distinct capture**. (What counts as an "other reference" is an open - question below.) + a new distinct capture**. (What counts as an "other reference" is settled + by the answer round — usage tied by the provenance/recapture system; see + the settled bullet below.) - **Replacement never destroys audio bytes.** "Replace" means the bank entry now denotes the recapture; the superseded file itself is not deleted by this action. *(Derived, not a Daniel quote: the settled rule that the @@ -1138,83 +1198,153 @@ through. audio parameters return to default — destructive to the dialed settings **by design**: the processing now lives in the recaptured audio, and the neutral controls are the starting point for the next iteration. Which - parameters "audio parameters" covers is an open question below. + parameters "audio parameters" covers is settled by the answer round — see + the reset-scope bullet below. - **No timeline item, ever.** Resampling is a capture act: it writes a file to the bank and updates the index; nothing is placed in the arrange view. *(Derived, not a Daniel quote: the tool's load-bearing capture/placement separation forces this — any framing of the feature that auto-inserts the recapture into the timeline is invalid.)* +- **Single capture, single parameter set — "the sound" is unambiguous.** + Answer 1 dissolves the multi-zone question by **system change, not scoping + rule**: the zone mapping system is retired outright — recorded in full as + **item 16**. With one capture = one parameter set, one click resamples the + instrument's one capture through its one parameter set; no multi-zone rule + is needed because no multi-zone state exists. *(Settled by answer round — + by reference to item 16.)* +- **The capture-signal popup.** A **popup menu programs the capture + signal**: **note length**, **start and end offsets — in ms AND in + beats**, and **velocity**. The popup carries a **preview trigger button** + that auditions the capture note exactly as currently programmed — the + user hears the bake before committing it — and the offline pass renders + that same programmed performance. *(Settled by answer round — a new + sub-feature, specced here with its own acceptance criteria below.)* + - **The note is the capture's root.** The rendered note is the capture's + root note — and, as Daniel states as a consequence, **the root-note + parameter is therefore not reset by resampling** (capturing at root is + what makes the root parameter survivable: it composes with the + reset-scope rule below, and resetting it would detune every subsequent + iteration). *(Settled by answer round.)* + - **Gate's hold and tail are answered by the programmed window.** The + programmed **note length is the Gate hold bound** — the gate holds for + the note length, then releases; item 9's loop-sustain cycles within the + held span and the render still terminates. The **end offset** is the + natural home of the tail policy: captured time past the note's end is + where the release rings. *(The hold reading is settled by the answer + round; the end-offset-as-tail reading is derived — the natural one, but + the offsets' exact anchor points are propose-at-review, below.)* + - **Velocity is explicit.** The programmed velocity is the render + velocity — material because the velocity transfer curves modulate amp + (and, with items 2 and 11, pitch and filter) at that velocity. + *(Settled by answer round.)* +- **Reset scope: only what the bake baked in.** *(Settled by answer round.)* + "Reinitialize the audio parameters to default" covers **only the + parameters whose effect is baked into the recaptured audio** — Daniel + names contours, filter, and master gain, with an explicit "etc". The + per-parameter classification follows *(derived, marked as derivation: the + rule is Daniel's; the reading-off is not)*: + - **Reset** (their effect is in the audio): the envelope contours — staged + and spline alike — the filter parameters, master gain, the pitch + envelope/engine settings, the velocity transfer curves (their effect at + the programmed velocity is in the audio), and the loop points (they + shaped the render, and old loop positions are meaningless against new + audio). + - **Survive** (mapping facts, not present in the audio): the **root + note** (explicit in answer 2), and — post-item-16 — whatever remains of + key mapping (key-tracking; any key-range concept item 16's open + question settles), plus the VOICE group (polyphony behavior leaves no + trace in a single rendered note). + - Edge classifications are verified at implementation review **against + the rule** — not new Daniel calls (open question below). +- **"Other references" = usage tied by the provenance/recapture system.** + *(Settled by answer round.)* Replace-vs-add is decided by whether **any + usage of the source capture is tied to it by the provenance/recapture + system**: tied usage exists → the original entry stays and the recapture + is added distinct; none → replace. Read plainly: the reference universe + is the resample system's own lineage records — **not** the + prune-protection universe. The answer does not appear to cover bank + multi-membership, items placed in the arrange, or a plain hold by another + instance outside any recapture lineage — those do not force add-distinct, + and need not for safety: the superseded file survives until prune, and + prune's protection universe is unchanged and broader than this one. How + the provenance/recapture system represents a "usage tie" is part of the + lineage question below — answer 4 makes that question load-bearing. +- **Undo/recovery: a plus, not a requirement.** *(Settled by answer round — + at exactly that strength.)* Undo of the bake chain is **desirable but not + required**: welcome if it falls out cheaply, and the feature ships + without it. The guaranteed recovery path remains the floor already stated + above — the superseded file survives on disk until a prune reclaims it. + This is a nice-to-have note, not a spec. **Open questions.** -- **Multi-zone scope — awaits a Daniel decision.** The ask speaks of "the - sound" and "that sampler capture" in the singular. On an instrument with - multiple zones — or zones referencing different captures — what does one - click resample: is the feature restricted to single-capture setups, does - it bake one zone (which?), or is there a whole-keymap story? The - single-capture reading is the natural one, but the multi-zone rule — even - if the rule is an explicit restriction — must be chosen, not assumed. -- **What the offline pass plays — awaits a Daniel decision.** "A trigger or - gate" presumably follows the active playback mode, but the render - performance is otherwise unstated: which note (natural lean: the capture's - root), what velocity (material, because the velocity curves modulate amp — - and, with items 2 and 11, pitch and filter), and for Gate mode a hold - length and tail policy — how long the gate holds (item 9's loop-sustain - makes a held gate indefinite, so *some* bound is required) and how long - the release rings before the capture ends. Trigger mode has a natural - answer (play to completion); Gate does not. -- **Reset scope — awaits a Daniel decision.** Does "reinitialize the audio - parameters to default" mean every parameter, or only those whose effect - the recapture baked in? Concretely undecided either way: zone key ranges, - loop points, the VOICE group, MASTER gain, and the velocity transfer - curves — which reset, which survive. -- **What counts as "other references" — awaits a Daniel decision.** Other - sampler instances holding the capture? Items placed in the arrange from - it? Its membership in more than one bank? The answer decides when - iteration updates the bank entry in place versus forks a distinct - capture — i.e., whether a sound something else still depends on can - disappear from the bank (its file survives regardless; see Behavior). The - prune model already has a settled notion of what protects a file from - deletion; whether that same universe governs replace-vs-add here is the - decision. -- **Undo / recovery — awaits a Daniel decision.** The chain is destructive - to the dialed settings by design. Is any of it undoable — one undo - restoring the bank entry and the instrument's prior parameters — or is - the loop deliberately forward-only, with the superseded file's - survival-until-pruned the only recovery path? The bank side has a settled - one-operation-one-undo convention; the instrument-parameter side has no - precedent to lean on. +- The five awaiting-a-Daniel-decision questions the fourth batch opened + (multi-zone scope; what the offline pass plays; reset scope; "other + references"; undo/recovery) are **all settled by the answer round** and + folded into Behavior above. What remains: +- **Capture-window semantics — propose at implementation review.** The + popup's dual ms/beats denomination is settled; its reference points are + not: the natural reading (start offset anchored to note-on, end offset to + note-end, beats resolved against the host tempo) should be proposed + concretely at review, along with whether negative offsets are meaningful. +- **Reset-scope edge cases — verify at implementation review.** The rule is + settled (baked-in resets, mapping survives); the per-parameter + classification above is derived. Any parameter whose side of the line is + unclear at implementation time is classified against the rule and + surfaced at review — not a new Daniel call. - **Extension presence — propose at implementation review.** The instrument plays self-contained with the extension absent, but the bank is the extension's surface, and resampling mutates the bank. The natural answer is that resample requires the extension present and is cleanly unavailable — not silently lossy — without it; propose the exact behavior at review. -- **Provenance of the recapture — propose at implementation review.** - Captures carry a reproducibility fingerprint of their capture recipe; a - resample's recipe is the instrument's own settings, not a track's chain. - Whether the recapture records a resample-shaped fingerprint or — the - settled conservative default for ambiguous cases — records nothing is a +- **Provenance of the recapture — propose at implementation review; now + load-bearing.** Captures carry a reproducibility fingerprint of their + capture recipe; a resample's recipe is the instrument's own settings, not + a track's chain. The answer round raises the stakes: replace-vs-add is + decided by usage ties in the provenance/recapture system (answer 4), so + the settled record-nothing conservative default is no longer available + for the lineage half of this question — the recapture must carry whatever + record makes answer 4's decision computable. Whether the + recipe-fingerprint half records a resample-shaped fingerprint stays a propose-at-review call. - **Naming and lineage — propose at implementation review.** When add-distinct fires, the new capture needs a display name (derived from the original?), and the bank some way to read iteration lineage across - repeated bakes; propose at review. + repeated bakes; propose at review, as one proposal with the provenance + question above, on which answer 4 now leans. +- **Multi-zone migration — owned by item 16, awaits Daniel there.** Not + this item's question, but it gates resample on pre-existing multi-zone + instances: what such an instance becomes under item 16's migration rule + decides what its resample would bake. **Acceptance criteria.** -- On a dialed-in single-capture instrument, one click yields all of: a - recapture in the bank, the instance holding that recapture, and the audio - parameters at their defaults (per whatever reset scope Daniel settles). +- On a dialed-in instrument (one capture, one parameter set — item 16), one + click yields all of: a recapture in the bank, the instance holding that + recapture, and the baked-in audio parameters at their defaults — with the + root note and the other surviving mapping parameters untouched (the + settled reset scope). - The bake is audible and faithful: after the click, playing what the - offline pass played (same note, same velocity, same mode) through the + offline pass played (the programmed capture note: root, at the programmed + length, offsets, and velocity, in the active mode) through the now-neutral controls sounds as the dialed instrument sounded just before the click — the processing has moved from the controls into the audio. +- The capture-signal popup exposes note length, start offset, and end + offset — each readable and editable in both ms and beats — plus velocity; + its preview trigger auditions the capture note exactly as programmed, and + the bake renders that same programmed performance (preview and bake + cannot diverge). +- A Gate-mode bake terminates on its own: the gate holds for the programmed + note length, then releases — even with item 9's loop-sustain active, the + render ends (no indefinite capture). +- After the bake the root note is unchanged — iteration never detunes: the + next bake plays the same root. - Sole-reference case: the bank afterwards shows the recapture where the source capture's entry was; no other bank entry is disturbed. - Other-references case: the original entry is untouched, a distinct new - entry appears, and every other holder of the original sounds exactly as - before. + Other-references case (provenance-tied usage of the original exists): the + original entry is untouched, a distinct new entry appears, and every + other holder of the original sounds exactly as before. - The click deletes no file: the superseded audio file still exists on disk afterwards, and only a later prune — under the settled orphan rules, only when nothing references it — can reclaim it. @@ -1223,3 +1353,97 @@ through. the first's result, repeatable indefinitely. - Save/reload: an instance holding a recapture reloads and plays it exactly like any other loaded capture. + +--- + +## 16 — Enhancement (simplification): retire the zone mapping system — one capture = one parameter set + +**Daniel's ask (verbatim, 2026-07-29 — answer 1 of item 15's answer round).** + +> I don't actually care, or even really understand, our zones. Retire the +> zone mapping system, simplify. I don't expect to use it, reasampler 9000 +> becomes 1 capture = one parameter set + +**Intent.** Simplification by deletion, not a feature: the multi-zone +keymap — zones with note ranges, per-zone parameters, and the dedicated +zone-editing surface — comes out of ReaSampler 9000 entirely. Daniel's +motive is stated plainly: he does not use it, does not care to understand +it, and wants the instrument simpler. The instrument becomes what its +common case already is: **one loaded capture played through one set of +parameters.** This arrived as answer 1 of item 15's answer round — it is a +far larger change than the question it answers (item 15's multi-zone +scope), which it dissolves rather than resolves; hence its own item. + +**Behavior.** + +- **One capture = one parameter set.** (Daniel's words.) The instrument + holds one loaded capture and one set of playback parameters governing it. + No zones, no per-zone divergence, no keymap of captures. +- **The zone-mapping surface goes away.** Forced by the decision, not + separately decided: the dedicated zone-editing surface and its authoring + affordances — add/delete zone, the per-zone parameter panel, and the + Low/High/Root zone legend — exist only to author zones and retire with + them. (The root note itself survives as a first-class parameter of the + one set — item 15's answer round makes it explicitly resample-stable; + only its zone-legend housing goes.) +- **The per-zone/per-instance storage distinction collapses.** Forced: + today playback parameters store per-zone, with VOICE and MASTER the + per-instance exceptions, and panel parity keeps the two editing surfaces + in step. With one parameter set there is nothing to be an exception to + and no second surface to keep in parity — every parameter simply belongs + to the instrument. Items 2, 9, and 11 carry superseded-by notes where + they asserted the per-zone convention. +- **The single-capture experience is unchanged.** *(Derived, not a Daniel + quote: the retirement removes the multi-zone superstructure, not the way + a single capture plays — today's single-capture instrument already + behaves as one capture with one parameter set.)* A loaded capture still + plays across the keyboard repitched from its root, key-tracking still + applies, and every queued enhancement (items 1–15) lands on the one + parameter set unchanged in substance. +- **No effect on the bank or the extension.** The zone system is an + instrument-side mapping concept; captures, banks, the capture/placement + separation, and the prune safety model are untouched. + +**Open questions.** + +- **Migration of saved multi-zone instances — awaits a Daniel decision.** A + project saved with an instance carrying multiple zones — possibly with + different parameters per zone, possibly referencing different captures — + must become *something* under the one-capture model, and no natural + answer exists: adopt one zone's capture and parameters (which one?), + refuse to lift and stay silent until re-pointed, or something else. Every + option changes what such a saved project sounds like, so the doc's + standing sounds-identical migration bar cannot hold for genuinely + multi-zone instances — the bar itself needs Daniel's ruling here. + (Instances that were single-zone all along — the expected overwhelming + majority, per his "I don't expect to use it" — lift losslessly under any + answer.) +- **Does any key-range concept survive? — propose at implementation + review.** With zones gone, does the capture respond across the entire + keyboard (repitched from root), or does a user-settable low/high playable + range survive as a plain parameter of the one set? Lean, consistent with + the simplification motive: no range concept — full-keyboard response — + with a low/high pair re-addable later as ordinary parameters if missed. + Not Daniel-blocking because the lean is cheap to reverse. +- **What the piano strip means with no zones — propose at implementation + review, jointly with item 12.** Item 12 keeps the full-width note-range + piano strip and adds note-name tooltips; item 15 needs the root + affordance. With no zone bars to draw, the strip's surviving jobs are the + root display/affordance and the tooltips — plus a range display only if + the key-range question keeps one. Propose the strip's exact contents at + review alongside item 12's work. + +**Acceptance criteria.** + +- The editor exposes no zone-mapping surface anywhere: no zone view or + button, no add/delete-zone affordance, no per-zone parameter panel, no + Low/High zone legend. The root-note control survives as a first-class + parameter. +- Every playback parameter edits in exactly one place and governs the + instrument as a whole; no gesture can express per-zone divergence. +- A single-capture instance saved before this change reopens sounding + identical — same keyboard response, same parameters, same root. +- A multi-zone instance saved before this change lifts per whatever + migration rule Daniel settles (gated on the open question above). +- The one parameter set round-trips save/reload intact. +- Bank, capture, placement, and prune behavior are unchanged. From b5788c82f67384c5181d4fac108f51264e07b0c3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:18:41 -0400 Subject: [PATCH 24/40] docs(todo-1.0): record second fourth-batch answer round Item 16 migration settled (adopt first zone); item 15 capture window specced, reset scope ratified; new item 17: provenance/usage consolidation. Nothing awaits Daniel. --- TODO-1.0.md | 342 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 283 insertions(+), 59 deletions(-) diff --git a/TODO-1.0.md b/TODO-1.0.md index 0e5882b..55d0f3a 100644 --- a/TODO-1.0.md +++ b/TODO-1.0.md @@ -6,7 +6,10 @@ extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering item 14 is a third, single-item batch (2026-07-28, later again); item 15 is a fourth, single-item batch (2026-07-29); item 16 (2026-07-29) is not a new ask batch — it records the system change mandated by answer 1 of item 15's -answer round (the zone mapping system is retired). +answer round (the zone mapping system is retired); item 17 (2026-07-29) is +likewise not a new ask batch — it records the requirement mandated by +answer 1 of the second fourth-batch answer round (the provenance/usage +tracking consolidated and made 100% robust). Deliberately specified at the level of product intent, user-visible behavior, and acceptance criteria — **no implementation design, no file/module references**. These were authored while Phase Q was @@ -107,9 +110,28 @@ and Sample/Zone-panel-parity language throughout (items 2, 9, 11, and 12 carry marked superseded-by/cross-reference notes; Daniel's verbatim asks stand exactly as written). Answer 2 spawns a new sub-feature inside item 15: a **capture-signal popup** (note length, start/end offsets in ms and in -beats, velocity, and a preview trigger). **Current Daniel-blocked state: -exactly one question awaits a Daniel decision — item 16's migration of saved -multi-zone instances.** Everything else open across items 1–16 is +beats, velocity, and a preview trigger). **Daniel-blocked state after that +round: exactly one question awaited a Daniel decision — item 16's migration +of saved multi-zone instances** (since settled; next paragraph). + +A **second fourth-batch answer round** (2026-07-29) — one message — settled +that migration question and answered the doc's three under-specification +flags. The unnumbered opening line rules the migration: **adopt the first +zone's capture and parameters** — a lossy rule Daniel accepts because he has +**no projects with zones in use**; for a genuinely multi-zone instance the +doc's standing sounds-identical migration bar is **deliberately relaxed** +(recorded in item 16). Answer 1 converts the doc's provenance-consolidation +flag into a requirement — **the provenance/usage tracking is to be +consolidated and made 100% robust** — recorded in full as **item 17**, on +which item 15's replace-vs-add rule now explicitly depends. Answer 2 +("that's good") ratifies item 15's derived reset-scope classification — +promoted from derived to confirmed by Daniel. Answer 3 settles the +capture-window semantics: the start/end offsets anchor to **note-on and +note-off** of the programmed note; note length is a **musical division** +(1/64th through 64/1, with dotted and triplet multipliers), not a free +duration; beats resolve against **the project tempo under the cursor**. +**Current Daniel-blocked state: no open question anywhere in items 1–17 +awaits a Daniel decision.** Everything still open is verify-or-propose-at-implementation. Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's @@ -144,6 +166,13 @@ set rather than per-zone, and item 12's full-width piano strip is re-grounded by what the strip means with zones retired. That too is dependency-of-meaning, not a schedule: the affected items' work is unchanged in substance; only its storage/parity framing simplifies. +Item 17 (provenance/usage consolidation) is likewise a **prerequisite of +meaning** for item 15's bank-side half — the replace-vs-add rule is +computable only once the consolidated lineage records exist — and is +independent of the editor chain (items 1–14); it reworks tracking machinery +today's prune protections ride, so those guarantees must hold undiminished +through the consolidation. Landing it with or before item 15's bank-side +half is the natural order. --- @@ -1154,6 +1183,23 @@ five questions this item carried as awaiting a Daniel decision: > system > 5. Undo/Recovery is a plus +**Daniel's second answer round (verbatim, 2026-07-29)** — the fourth +batch's second answer round. The unnumbered opening line answers item 16's +migration question (carried there); the numbered answers map, in order, +onto the doc's three under-specification flags: the +provenance-consolidation flag (→ item 17), this item's derived reset-scope +classification, and this item's capture-window semantics: + +> adopt the first zone's capture and parameters, I don't have any projects +> with zones used +> +> 1. the provenance/usage tracking will need to be consolidated and made +> 100% robust +> 2. that's good +> 3. yeah relative to note-on and note-off of the programed note length +> (1/8., 1/4t, 1/16, 4/1 etc (1/64th to 64/1 with dotted and triplet +> multipliers)). the project tempo under the item cursor. + **Intent.** Close the sound-design loop from inside the instrument: once the filtering, pitching, and amp are set up nice, one click bakes that processing into the audio itself — a new bank capture — and hands the instrument back at @@ -1225,14 +1271,29 @@ through. what makes the root parameter survivable: it composes with the reset-scope rule below, and resetting it would detune every subsequent iteration). *(Settled by answer round.)* + - **Note length is a musical division, not a free duration.** The + programmed note length is chosen from musical divisions spanning + **1/64th through 64/1, with dotted and triplet multipliers** (Daniel's + examples: `1/8.`, `1/4t`, `1/16`, `4/1`). *(Settled by second answer + round.)* + - **Offsets anchor to note-on and note-off.** The start offset is + relative to the programmed note's **note-on**; the end offset is + relative to its **note-off**. *(Settled by second answer round.)* + - **Beats resolve against the project tempo under the cursor.** A + beat-denominated value — the note-length division always, the offsets + when expressed in beats — resolves to time against **"the project + tempo under the item cursor"** (Daniel's phrase; read plainly: the + tempo in effect at the project's cursor position when the preview or + bake runs). *(Settled by second answer round.)* - **Gate's hold and tail are answered by the programmed window.** The programmed **note length is the Gate hold bound** — the gate holds for the note length, then releases; item 9's loop-sustain cycles within the held span and the render still terminates. The **end offset** is the natural home of the tail policy: captured time past the note's end is where the release rings. *(The hold reading is settled by the answer - round; the end-offset-as-tail reading is derived — the natural one, but - the offsets' exact anchor points are propose-at-review, below.)* + round; the anchors are settled by the second answer round — with the + end offset anchored to note-off, the tail reading is direct, no longer + a derivation.)* - **Velocity is explicit.** The programmed velocity is the render velocity — material because the velocity transfer curves modulate amp (and, with items 2 and 11, pitch and filter) at that velocity. @@ -1241,8 +1302,10 @@ through. "Reinitialize the audio parameters to default" covers **only the parameters whose effect is baked into the recaptured audio** — Daniel names contours, filter, and master gain, with an explicit "etc". The - per-parameter classification follows *(derived, marked as derivation: the - rule is Daniel's; the reading-off is not)*: + per-parameter classification follows — it began as this doc's derivation + and is now **confirmed by Daniel** ("that's good," second answer round); + the record that it originated as a reading-off is preserved, but the + reset/survive lists below are ratified, not derived: - **Reset** (their effect is in the audio): the envelope contours — staged and spline alike — the filter parameters, master gain, the pitch envelope/engine settings, the velocity transfer curves (their effect at @@ -1268,7 +1331,11 @@ through. and need not for safety: the superseded file survives until prune, and prune's protection universe is unchanged and broader than this one. How the provenance/recapture system represents a "usage tie" is part of the - lineage question below — answer 4 makes that question load-bearing. + lineage question below — answer 4 makes that question load-bearing, and + the second answer round's answer 1 converts the dependency into a + requirement in its own right: **item 17** (the provenance/usage tracking + consolidated and made 100% robust), on which this rule now explicitly + depends. - **Undo/recovery: a plus, not a requirement.** *(Settled by answer round — at exactly that strength.)* Undo of the bake chain is **desirable but not required**: welcome if it falls out cheaply, and the feature ships @@ -1282,41 +1349,48 @@ through. (multi-zone scope; what the offline pass plays; reset scope; "other references"; undo/recovery) are **all settled by the answer round** and folded into Behavior above. What remains: -- **Capture-window semantics — propose at implementation review.** The - popup's dual ms/beats denomination is settled; its reference points are - not: the natural reading (start offset anchored to note-on, end offset to - note-end, beats resolved against the host tempo) should be proposed - concretely at review, along with whether negative offsets are meaningful. +- **Capture-window residuals — propose at implementation review.** The + second answer round settles the anchors (note-on / note-off), the tempo + source (the project tempo under the cursor), and note length as a + musical division (1/64th–64/1, dotted/triplet). Two residuals remain: + whether negative offsets are meaningful (unchanged from before); and a + denomination seam — the first answer round expressed the offsets "in ms + AND in beats" while note length is now musical-division-only. The plain + reading is that the ms/beats duality applies to the offsets only; if an + ms display or entry for note length seems wanted at implementation, + propose it at review rather than assuming either way. - **Reset-scope edge cases — verify at implementation review.** The rule is - settled (baked-in resets, mapping survives); the per-parameter - classification above is derived. Any parameter whose side of the line is - unclear at implementation time is classified against the rule and - surfaced at review — not a new Daniel call. + settled and the per-parameter classification above is now ratified by + the second answer round ("that's good"). Only a genuinely new parameter — + one arriving with a queued item and absent from the ratified lists — is + classified against the rule and surfaced at review; not a new Daniel + call. - **Extension presence — propose at implementation review.** The instrument plays self-contained with the extension absent, but the bank is the extension's surface, and resampling mutates the bank. The natural answer is that resample requires the extension present and is cleanly unavailable — not silently lossy — without it; propose the exact behavior at review. -- **Provenance of the recapture — propose at implementation review; now - load-bearing.** Captures carry a reproducibility fingerprint of their - capture recipe; a resample's recipe is the instrument's own settings, not - a track's chain. The answer round raises the stakes: replace-vs-add is - decided by usage ties in the provenance/recapture system (answer 4), so - the settled record-nothing conservative default is no longer available - for the lineage half of this question — the recapture must carry whatever - record makes answer 4's decision computable. Whether the - recipe-fingerprint half records a resample-shaped fingerprint stays a - propose-at-review call. -- **Naming and lineage — propose at implementation review.** When - add-distinct fires, the new capture needs a display name (derived from - the original?), and the bank some way to read iteration lineage across - repeated bakes; propose at review, as one proposal with the provenance - question above, on which answer 4 now leans. -- **Multi-zone migration — owned by item 16, awaits Daniel there.** Not - this item's question, but it gates resample on pre-existing multi-zone - instances: what such an instance becomes under item 16's migration rule - decides what its resample would bake. +- **Provenance of the recapture — homed in item 17.** Captures carry a + reproducibility fingerprint of their capture recipe; a resample's recipe + is the instrument's own settings, not a track's chain. The answer round + made this load-bearing (replace-vs-add is decided by usage ties, so the + settled record-nothing conservative default is no longer available for + the lineage half — the recapture must carry whatever record makes + answer 4's decision computable), and the second answer round's answer 1 + converts it into **item 17**, where the consolidation requirement and + its open questions now live. This item's dependency stands: its + replace-vs-add half is meaningful only once item 17's consolidated + lineage exists. +- **Naming and lineage — propose at implementation review, jointly with + item 17.** When add-distinct fires, the new capture needs a display name + (derived from the original?), and the bank some way to read iteration + lineage across repeated bakes; propose at review as one proposal with + item 17's lineage-record question, on which answer 4 leans. +- (The multi-zone migration question this list previously carried is + settled in item 16 by the second answer round — adopt the first zone's + capture and parameters; a pre-existing multi-zone instance's resample + therefore bakes its first zone's sound.) **Acceptance criteria.** @@ -1330,11 +1404,16 @@ through. length, offsets, and velocity, in the active mode) through the now-neutral controls sounds as the dialed instrument sounded just before the click — the processing has moved from the controls into the audio. -- The capture-signal popup exposes note length, start offset, and end - offset — each readable and editable in both ms and beats — plus velocity; - its preview trigger auditions the capture note exactly as programmed, and - the bake renders that same programmed performance (preview and bake - cannot diverge). +- The capture-signal popup exposes: note length as a musical-division + picker spanning 1/64th to 64/1 with dotted and triplet multipliers; + start and end offsets, each readable and editable in both ms and beats, + anchored to note-on and note-off respectively; and velocity. Its preview + trigger auditions the capture note exactly as programmed, and the bake + renders that same programmed performance (preview and bake cannot + diverge). +- Beat-denominated values resolve against the project tempo under the + cursor: the same programmed division yields a correspondingly different + rendered duration when the tempo at the cursor differs. - A Gate-mode bake terminates on its own: the gate holds for the programmed note length, then releases — even with item 9's loop-sustain active, the render ends (no indefinite capture). @@ -1342,9 +1421,10 @@ through. next bake plays the same root. - Sole-reference case: the bank afterwards shows the recapture where the source capture's entry was; no other bank entry is disturbed. - Other-references case (provenance-tied usage of the original exists): the - original entry is untouched, a distinct new entry appears, and every - other holder of the original sounds exactly as before. + Other-references case (provenance-tied usage of the original exists — + the tie computed by item 17's consolidated tracking): the original entry + is untouched, a distinct new entry appears, and every other holder of + the original sounds exactly as before. - The click deletes no file: the superseded audio file still exists on disk afterwards, and only a later prune — under the settled orphan rules, only when nothing references it — can reclaim it. @@ -1364,6 +1444,12 @@ through. > zone mapping system, simplify. I don't expect to use it, reasampler 9000 > becomes 1 capture = one parameter set +**Daniel's follow-up (verbatim, 2026-07-29 — the second answer round's +unnumbered opening line, answering this item's migration question).** + +> adopt the first zone's capture and parameters, I don't have any projects +> with zones used + **Intent.** Simplification by deletion, not a feature: the multi-zone keymap — zones with note ranges, per-zone parameters, and the dedicated zone-editing surface — comes out of ReaSampler 9000 entirely. Daniel's @@ -1393,6 +1479,24 @@ scope), which it dissolves rather than resolves; hence its own item. and no second surface to keep in parity — every parameter simply belongs to the instrument. Items 2, 9, and 11 carry superseded-by notes where they asserted the per-zone convention. +- **Migration: adopt the first zone's capture and parameters.** *(Settled + by the second answer round.)* A saved multi-zone instance lifts to the + one-capture model by adopting its **first zone's** capture and that + zone's parameters; the remaining zones' captures and parameters drop + from the instance. Dropping a zone touches no file and no bank entry — + the instance simply no longer holds those captures; bank and prune + behavior are unchanged, per the bullet below. Daniel's stated rationale + is what makes a lossy rule acceptable here: **he has no projects with + zones in use**, so the migration path carries essentially no real-world + risk — no actual project sounds different under it. Consequently, for a + genuinely multi-zone instance the doc's standing sounds-identical + migration bar is **deliberately relaxed**: such an instance reopens + playing its first zone's sound only, and that is the accepted outcome — + accepted for that reason. Single-zone instances — the actual universe — + lift losslessly and reopen sounding identical, so the bar holds + everywhere it has real referents. (Which zone is "first" — the + instance's existing zone ordering — is a verify-at-implementation + detail with no real-world stakes, given the rationale.) - **The single-capture experience is unchanged.** *(Derived, not a Daniel quote: the retirement removes the multi-zone superstructure, not the way a single capture plays — today's single-capture instrument already @@ -1406,18 +1510,12 @@ scope), which it dissolves rather than resolves; hence its own item. **Open questions.** -- **Migration of saved multi-zone instances — awaits a Daniel decision.** A - project saved with an instance carrying multiple zones — possibly with - different parameters per zone, possibly referencing different captures — - must become *something* under the one-capture model, and no natural - answer exists: adopt one zone's capture and parameters (which one?), - refuse to lift and stay silent until re-pointed, or something else. Every - option changes what such a saved project sounds like, so the doc's - standing sounds-identical migration bar cannot hold for genuinely - multi-zone instances — the bar itself needs Daniel's ruling here. - (Instances that were single-zone all along — the expected overwhelming - majority, per his "I don't expect to use it" — lift losslessly under any - answer.) +- The migration question this item carried as awaiting a Daniel decision + is **settled by the second answer round** — adopt the first zone's + capture and parameters — and folded into Behavior above, with his + rationale (no projects with zones in use) recorded there. **Nothing in + this item awaits a Daniel decision.** What remains is + propose-at-implementation-review: - **Does any key-range concept survive? — propose at implementation review.** With zones gone, does the capture respond across the entire keyboard (repitched from root), or does a user-settable low/high playable @@ -1443,7 +1541,133 @@ scope), which it dissolves rather than resolves; hence its own item. instrument as a whole; no gesture can express per-zone divergence. - A single-capture instance saved before this change reopens sounding identical — same keyboard response, same parameters, same root. -- A multi-zone instance saved before this change lifts per whatever - migration rule Daniel settles (gated on the open question above). +- A multi-zone instance saved before this change reopens holding its first + zone's capture with that zone's parameters — no error, no file touched, + no bank entry disturbed. (The sounds-identical bar deliberately does not + apply to this case: the instance reopens playing the first zone's sound + only — the settled, accepted outcome.) - The one parameter set round-trips save/reload intact. - Bank, capture, placement, and prune behavior are unchanged. + +--- + +## 17 — Enhancement (requirement): consolidate provenance/usage tracking — one system, 100% robust + +**Daniel's ask (verbatim, 2026-07-29 — answer 1 of the second fourth-batch +answer round).** + +> the provenance/usage tracking will need to be consolidated and made 100% +> robust + +**Intent.** A requirement, not a feature: the tracking machinery in the +provenance/usage territory becomes one coherent, fully robust system. This +began as a doc flag — item 15's answer 4 made recapture lineage +load-bearing while the territory's mechanisms were separate and ad-hoc — +and Daniel's answer converts the flag into a standing requirement. Today +the territory holds two separately-grown mechanisms plus one new demand: +(1) the **capture-recipe fingerprint** — a thin reproducibility record of +how a capture was made, deliberately not a restorable chain, conservatively +recording nothing when the situation is ambiguous; (2) the **instance-usage +tracking** — each live instrument instance declares the captures it holds, +so the prune can never delete a capture a live instance is using, with a +fail-safe stance that unreadable usage state halts the prune entirely; and +(3) item 15's demand for **recapture lineage** — records tying usages of a +capture to it "by the provenance/recaptureing system," deciding +replace-vs-add at bake time. "Consolidated" plainly means these stop being +separate ad-hoc mechanisms: one tracking system in which recipe provenance, +live usage, and recapture lineage are facets of the same record-keeping. +"100% robust" is a strength statement Daniel chose deliberately: this +territory gates the system's only file-deletion authority (prune) and its +only capture-replacement act (item 15's resample) — the two places where a +tracking error loses a user's audio or sound. + +**Behavior.** + +- **One system, not three mechanisms.** Recipe provenance, live-instance + usage, and recapture lineage are kept by one consolidated tracking + system, and every consumer — the prune's protection decision, the + resample's replace-vs-add decision, and any future lineage reader — is + answered from it. +- **What "100% robust" observably means.** Daniel stated the strength, not + the mechanics; the territory's stakes force three observable + implications, recorded without inventing further specifics: + - **No silent gaps.** Every capture the system itself creates is tracked + from the moment of its creation; a recapture carries its lineage from + birth, never backfilled. There is no window in which a + system-created file exists untracked. + - **Fail-safe on unreadable or ambiguous state.** Tracking state that + cannot be read never yields the destructive answer: the prune deletes + nothing (today's settled stance — preserved and generalized, not + relaxed), and the resample never takes the replace branch on + unreadable lineage (whether the safe branch is add-distinct or a halt + with a clear message is propose-at-review). + - **Consumers cannot disagree.** The prune's protection answer and the + resample's tied-usage answer are different questions with different + universes — item 15 settles that the replace-vs-add universe is + narrower than the prune-protection universe — but both are computed + from the same consolidated records, so they cannot drift apart or + contradict. +- **Existing guarantees are the floor.** Consolidation must not weaken + anything now settled: the prune still deletes only the system's own + orphans and never a referenced or live-held capture; the fail-safe abort + on unreadable usage state survives; the recipe fingerprint's + record-nothing-when-ambiguous conservatism survives for the recipe half. + The lineage half is the one place that conservatism is foreclosed — + item 15's answer 4 makes the lineage record mandatory for a recapture, + since replace-vs-add must be computable. +- **Dependency of meaning for item 15.** Item 15's replace-vs-add rule is + computable only once this item's consolidated lineage exists; item 15's + provenance-of-the-recapture question is homed here, and its + naming-and-lineage question is proposed jointly with this item's + lineage-record question. This is dependency of meaning, not merely + sequencing: without this item, "usage tied by the provenance/recapture + system" denotes nothing. + +**Open questions.** + +- None awaiting a Daniel decision — the requirement and its strength are + his; the shape is review work: +- **The consolidated shape — propose at implementation review.** What "one + system" concretely is (one record family, one authority, how the three + facets relate) is design work proposed at review, not a Daniel call. +- **The lineage record — propose at implementation review, jointly with + item 15's naming-and-lineage question.** What constitutes a "usage tie," + when it is written, whether it is ever severed, and whether iteration + lineage is user-readable from the bank. +- **The recipe-fingerprint half of a recapture — propose at implementation + review.** A resample's recipe is the instrument's own settings, not a + track chain; whether the fingerprint records a resample-shaped recipe — + and what its conservatism means there — is proposed at review. (The + lineage half has no record-nothing option; the recipe half may keep + one.) +- **Never-recorded vs. unreadable — propose at implementation review.** + Pre-existing captures predate lineage records, and the two absences + demand opposite treatment: never-recorded means no tied usage exists + (replace is legitimate, per item 15's settled rule); unreadable means + fail-safe. How the consolidated system distinguishes them — and how + pre-existing banks lift in without weakening any protection they enjoy + today — is proposed at review. + +**Acceptance criteria.** + +- One consolidated tracking system answers both safety-critical consumers: + the prune's protected set and the resample's replace-vs-add decision are + each computed from it, per their own settled rules; no separate ad-hoc + tracking mechanism remains in the territory. +- No silent gaps: a recapture created by item 15's bake is tracked from + the instant it exists — a bake followed immediately by a prune, or by a + second bake, behaves correctly with no window in which the recapture is + untracked or its lineage absent. +- Fail-safe throughout: with tracking state made unreadable, the prune + deletes nothing (and reports what blocked it, per today's behavior) and + the resample never takes the replace branch; no destructive act follows + from ambiguity, anywhere in the territory. +- Every protection settled today holds undiminished after consolidation: a + capture held by a live instance cannot be pruned; files the system did + not create are untouchable; unreadable usage state still halts the + prune. +- A pre-existing bank lifts into the consolidated system with no loss of + protection and no spurious lineage, and never-recorded remains + distinguishable from unreadable. +- Item 15's other-references case is decidable: for any capture, "does + provenance-tied usage exist" has a definite yes/no answer. From 30a4ffd01bf10bee9bf49a606a89b7c9010deae3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 10:55:58 -0400 Subject: [PATCH 25/40] =?UTF-8?q?Q-W2:=20split=20bank=5Fpanel.cpp=20(3459?= =?UTF-8?q?=20LOC)=20into=20eight=20shell/panel=20TUs=20=E2=80=94=20reasam?= =?UTF-8?q?pler::panel=20internals,=20per-seam=20public=20headers,=20shim?= =?UTF-8?q?=20retired;=20zero=20behavior=20change,=2060/60=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 9 +- src/actions.cpp | 4 +- src/app/main.cpp | 4 +- src/bank_panel.cpp | 3460 -------------------------- src/bank_panel.h | 131 - src/ingest.cpp | 2 +- src/shell/capture/insert.cpp | 2 +- src/shell/panel/panel_audition.cpp | 117 + src/shell/panel/panel_bank_ops.cpp | 478 ++++ src/shell/panel/panel_bank_ops.h | 43 + src/shell/panel/panel_drag.cpp | 503 ++++ src/shell/panel/panel_input.cpp | 628 +++++ src/shell/panel/panel_input.h | 43 + src/shell/panel/panel_layout.cpp | 558 +++++ src/shell/panel/panel_layout.h | 43 + src/shell/panel/panel_render.cpp | 609 +++++ src/shell/panel/panel_state.h | 606 +++++ src/shell/panel/panel_thumbnails.cpp | 155 ++ src/shell/panel/panel_window.cpp | 243 ++ src/shell/panel/panel_window.h | 42 + 20 files changed, 4084 insertions(+), 3596 deletions(-) delete mode 100644 src/bank_panel.cpp delete mode 100644 src/bank_panel.h create mode 100644 src/shell/panel/panel_audition.cpp create mode 100644 src/shell/panel/panel_bank_ops.cpp create mode 100644 src/shell/panel/panel_bank_ops.h create mode 100644 src/shell/panel/panel_drag.cpp create mode 100644 src/shell/panel/panel_input.cpp create mode 100644 src/shell/panel/panel_input.h create mode 100644 src/shell/panel/panel_layout.cpp create mode 100644 src/shell/panel/panel_layout.h create mode 100644 src/shell/panel/panel_render.cpp create mode 100644 src/shell/panel/panel_state.h create mode 100644 src/shell/panel/panel_thumbnails.cpp create mode 100644 src/shell/panel/panel_window.cpp create mode 100644 src/shell/panel/panel_window.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 09e9d7b..6bf216f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1068,7 +1068,14 @@ add_library(reaper_reasampler MODULE src/shell/capture/capture_realtime.cpp src/core/capture/realtime_record.cpp src/persist.cpp - src/bank_panel.cpp + src/shell/panel/panel_audition.cpp + src/shell/panel/panel_bank_ops.cpp + src/shell/panel/panel_drag.cpp + src/shell/panel/panel_input.cpp + src/shell/panel/panel_layout.cpp + src/shell/panel/panel_render.cpp + src/shell/panel/panel_thumbnails.cpp + src/shell/panel/panel_window.cpp src/shell/panel/draw_kit.cpp src/core/view/mode_switch.cpp src/core/ui/tab_strip.cpp diff --git a/src/actions.cpp b/src/actions.cpp index 6f025b3..488d6a0 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -29,7 +29,9 @@ #include "core/version/app_version.h" // channelCommandId / channelActionName — one channel-identity point #include "core/model/bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) -#include "bank_panel.h" // selection seam + full-height toggles (B3/B4) +#include "shell/panel/panel_bank_ops.h" // selection seam (B3/B4) +#include "shell/panel/panel_layout.h" // full-height toggles (B3) +#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "core/view/lane_keys.h" // isOnManualLane — the single managed/manual predicate #include "persist.h" // ReaSamplerSession (owns book() + view() model) diff --git a/src/app/main.cpp b/src/app/main.cpp index 3c5aafb..92f1daf 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -30,7 +30,9 @@ #include "actions.h" #include "core/version/app_version.h" #include "core/model/bank_model.h" -#include "bank_panel.h" +#include "shell/panel/panel_bank_ops.h" // selection read seam (insert action) +#include "shell/panel/panel_input.h" // bankPanelRefresh / project-load notify / tail seam +#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/shutdown) #include "core/capture/batch_capture.h" #include "shell/capture/capture.h" #include "ingest.h" diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp deleted file mode 100644 index 94d6fd2..0000000 --- a/src/bank_panel.cpp +++ /dev/null @@ -1,3460 +0,0 @@ -#include "core/namespaces.h" -// bank_panel.cpp — REAPER-facing docked grid (M5 Wave A/B + Phase B4). See -// bank_panel.h. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). -// -// What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit -// tested): -// * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via -// DockWindowRemove; toggled open/closed. -// * WM_PAINT: a VERTICAL SPLIT (Phase B4) — the pool grid region on top, a -// LICE-drawn named-banks tab-page region below (one tab per named bank, an -// overflow/scroll strip), and two full-height toggles that collapse the split. -// Each region reuses the M5 grid render loop (waveform thumbnails / empty state). -// * per-sample PCM read via PCM_source fed to peaks::computeEnvelope, one bin per -// drawn pixel column; drawWaveform's gap-free render comes from peaks::columnMinMax. -// * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation). -// * id-keyed bank management (create / rename / delete / evacuate / activate) and -// sample move/copy — driven from a tab context menu and a drag — against the B1 -// BankBook model on g_session.book(), persisted via g_session.saveToActiveProject(). -// -// READ-ONLY of the TIMELINE (load-bearing principle): this panel never inserts into -// the arrange. It DOES mutate the bank BOOK (create/rename/move/etc.) — that is the -// whole point of B4 — but only the index/model + ext-state, never the arrange, never -// a sample file on disk (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). -// -// THE PURE SEAMS: grid tiling / hit-test / selection math live in bank_grid; the -// mode-switch geometry in mode_switch; the named-banks TAB-STRIP layout, overflow/ -// scroll, and hit-test in tab_strip. All three are unit-tested outside the DAW; only -// draw + input routing + the model calls live here. -// -// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural -// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, -// so a BankModel& / Bank* must NEVER be cached across one. Every handler below -// resolves fresh AFTER any mutation and passes bank IDS (not references) into the -// model ops. - -#include "bank_panel.h" - -#include -#include // std::abs (drag threshold) -#include -#include -#include -#include -#include -#include - -#include "core/ui/action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2) -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) -#include "core/ui/drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) -#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) -#include "core/version/app_version.h" // channelCommandId — compose the named-command lookup string (M11) -#include "core/model/bank_book.h" -#include "core/ui/bank_grid.h" -#include "core/model/bank_model.h" -#include "core/ui/card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test -#include "core/ui/card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure) -#include "core/capture/capture_paths.h" -#include "core/ui/component_geometry.h" // KitBox — the kit text()'s draw box (L1) -#include "shell/panel/draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) -#include "core/ui/footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) -#include "core/view/guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) -#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest -#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17) -#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) -#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) -#include "core/view/lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) -#include "core/ui/mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) -#include "core/view/mode_switch.h" -#include "core/ui/overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) -#include "core/audio/peaks.h" -#include "persist.h" -#include "core/ui/prune_button.h" // footer prune-button layout + hit-test (pure, R3) -#include "core/ui/tooltip.h" // tooltip placement + prefix-strip (pure, L5) -#include "core/capture/render_settings.h" // captureActionTable — the table-driven button rows (M11) -#include "core/ui/tab_strip.h" -#include "core/capture/tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) -#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) -#include "shell/view/view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires -#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) - -// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); -// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). -#ifdef _WIN32 -#include -#include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) -#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest -#else -#include -#endif -#include "wdltypes.h" -#include "swell/swell.h" -#include "lice/lice.h" - -#include "resource.h" - -#include "reaper_plugin.h" - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_DockWindowAddEx -#define REAPERAPI_WANT_DockWindowActivate -#define REAPERAPI_WANT_DockWindowRemove -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project) -#define REAPERAPI_WANT_GetMainHwnd -#define REAPERAPI_WANT_PCM_Source_CreateFromFile -#define REAPERAPI_WANT_PCM_Source_Destroy -// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane -// state to classify an item's lane as managed vs manual. -#define REAPERAPI_WANT_CountTracks -#define REAPERAPI_WANT_GetTrack -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_CountTrackMediaItems -#define REAPERAPI_WANT_GetTrackMediaItem -// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): -// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the -// STOCK symbols (not SWS-only) — see the audition section below. -#define REAPERAPI_WANT_PlayPreview -#define REAPERAPI_WANT_StopPreview -#define REAPERAPI_WANT_GetUserInputs -#define REAPERAPI_WANT_ShowMessageBox -#define REAPERAPI_WANT_Main_OnCommand // fire the prune action by command id (R3 button) -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -// Action-trigger buttons (M11): resolve each button's command id at runtime from the -// composed named-command string, fire it through the existing action contract, and read -// its current key binding for the reminder label. All main-section (SectionFromUniqueID(0)). -#define REAPERAPI_WANT_NamedCommandLookup -#define REAPERAPI_WANT_Main_OnCommand -#define REAPERAPI_WANT_kbd_getTextFromCmd -#define REAPERAPI_WANT_SectionFromUniqueID -#include "reaper_plugin_functions.h" - -// main.cpp owns the module instance handle and REAPER's dispatch struct. -extern REAPER_PLUGIN_HINSTANCE g_hInst; -extern reaper_plugin_info_t* g_rec; - -namespace reasampler { - -namespace { - -namespace fs = std::filesystem; - -// --- Layout constants --------------------------------------------------------- -// -// L2: every panel COLOR now comes from the pure `theme` module by ROLE (drawn through the L1 -// kit — fillSurface / drawButton / kit text). The former flat LICE_RGBA / RGB palette blocks -// are retired; only the pixel LAYOUT metrics (band heights, grid/tab specs, insets) live here. - -const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; - -constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) - -// --- Footer (Phase L, L4) ----------------------------------------------------- -// The footer now carries a task-cluster of small persistent controls: the narrowed -// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 — -// a real kit button, no longer a click-zone), and the set-apart Prune button at the -// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly. -// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height. -constexpr int kFooterHeight = 30; - -// --- Toolbars (Phase L, L4) --------------------------------------------------- -// TWO task-grouped toolbars, both drawn through the pure action_bar module: -// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of -// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there. -// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above -// the footer (L4 §2). This is the L2 action-bar band, repurposed. -// The kBarSpec metrics the bars consume live near the draw below; only heights live here. -constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) -constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent - -// --- Tooltip (Phase L, L5) ---------------------------------------------------- -// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the -// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the -// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font -// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is -// generous — a slight over/under-estimate only pads the box, never clips the text). -constexpr unsigned int kTooltipDelayMs = 500; -constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) -constexpr int kTooltipTextH = 14; // approx line height at Font::Label - -// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- -// -// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | -// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer -// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool -// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER -// band: a title, the active-bank readout, and a full-height toggle button. The named-banks -// region's header ALSO hosts the LICE tab strip and a "+" create button. -constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band -constexpr int kTabStripHeight = 26; // the named-banks tab strip band -constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions -constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button -constexpr int kCreateBtnWidth = 22; // the "+" create-bank button - -// Tab strip metrics (the pure tab_strip owns the math; these are its inputs). -const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; - -// --- Panel state -------------------------------------------------------------- - -struct CachedThumbnail { - Envelope envelope; - int width = 0; -}; - -// Which of the two split regions currently owns the selection / receives keyboard -// input. The move/copy source is the focused region's displayed bank. -enum class Region { Pool, Banks }; - -// What a drag is dropping onto, resolved live under the pointer during a drag. -// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT -// on a specific tab (tab takes precedence — more specific wins). The resolved bank is -// always shownBankId. -enum class DropKind { None, PoolRegion, Tab, BanksRegion }; - -// --- Hover model (Phase L, L2) ------------------------------------------------ -// -// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its -// hover state on that element only (the "hover on every interactive element" + "sub-frame -// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep -// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None -// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. -// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. -enum class HoverKind { - None, - TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows) - BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows) - MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) - PruneButton, - FullHtPool, // pool region full-height toggle - FullHtBanks, // banks region full-height toggle - CreateBank, // the "+" create-bank button - Tab, // a named-bank tab (index = tab ordinal) - TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) - ModeSegment, // a footer mode-toggle segment (index = segment ordinal) -}; - -struct Hover { - HoverKind kind = HoverKind::None; - int index = -1; - - bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; } - bool operator!=(const Hover& o) const { return !(*this == o); } -}; - -// The kit interaction state for an interactive element: Hover when this (kind,index) is the -// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. -// an active tab draws Active regardless of hover); this is the base rest/hover resolver. -InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { - return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover - : InteractionState::Rest; -} - -struct PanelState { - ReaSamplerSession* session = nullptr; - - HWND hwnd = nullptr; - bool open = false; - - std::string bankFingerprint; - std::uint64_t generation = 0; - - std::unordered_map cache; - - // --- Selection (per focused region) --------------------------------------- - // One live selection, scoped to `focusedRegion`. Switching regions moves the - // selection with the focus (a click in the other region reseeds it there). - Selection selection; - int selItemCount = 0; - Region focusedRegion = Region::Pool; - - // --- Hover (Phase L, L2) -------------------------------------------------- - // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its - // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). - Hover hovered; - - // --- Tooltip (Phase L, L5) ------------------------------------------------ - // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a - // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel - // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the - // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover - // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly - // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the - // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. - unsigned int hoverSinceTick = 0; - bool tooltipShown = false; - - // --- Vertical-split state ------------------------------------------------- - BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; - - // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT - // from the active/capture-target bank (book().activeBankId()). Empty when there - // are no named banks. Reconciled each fingerprint pass so it always names a live - // named bank (or is empty). - std::string shownBankId; - - // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. - int tabScroll = 0; - - // --- Drag (sample move between regions/onto a tab) ------------------------ - // A drag begins only after the pointer moves past a threshold from a press that - // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 - // multi-select drag (which begins immediately on any grid press). See onLBtnDown/ - // onMouseMove. dragging is true once the threshold is crossed. - bool dragArmed = false; // pressed on a selected cell; watching for threshold - bool dragging = false; // threshold crossed; a move-drag is in progress - int dragStartX = 0, dragStartY = 0; - Region dragSourceRegion = Region::Pool; - std::string dragSourceBankId; // the bank the dragged samples come from - std::vector dragSampleIds;// snapshot of the selection at drag start - std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 - // reorder/replace subject (see onLBtnUp dispatch) - DropKind dropKind = DropKind::None; // live drop target under the pointer - std::string dropBankId; // destination bank id when dropKind==Tab - - // --- L7 in-grid reorder/replace drag -------------------------------------- - // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- - // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the - // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 - // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue - // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. - CardGesture cardGesture = CardGesture::None; - int dragTargetSlot = -1; - - // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- - // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an - // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX - // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the - // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last - // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on - // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. - MediaTrack* instrumentDropTrack = nullptr; - - // --- Tail-mode toggle ----------------------------------------------------- - // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), - // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, - // restores it on project load). The panel reads it for drawing and mutates it via - // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the - // project dirty so the choice saves. bankPanelTailSetting is the read seam for the - // capture actions. Held here only through the session pointer above. - - // --- Audition preview ----------------------------------------------------- - preview_register_t preview{}; - PCM_source* previewSrc = nullptr; - bool previewActive = false; - bool previewInited = false; // guards double init / deinit - - // --- New-content detection (D2 Wave 2) ------------------------------------ - // - // Each timer tick diffs the live track+item GUID set against the previous tick to - // auto-tag content created SINCE the last tick into the then-active mode. The - // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its - // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). - // - // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a - // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact - // tick persist restores a project's membership + active mode (the same tick it - // reapplies the active mode); that sets reloadPending so the NEXT detect tick this - // same tick re-baselines against the fully-loaded set and reports nothing new. This - // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than - // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto - // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then - // diffed against the previous project's stale baseline and were mass-tagged into the - // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the - // two identity checks agree by construction. - // - // Lives for the extension's lifetime alongside the session, independent of panel - // open/close — detection must run whether or not the dock is visible (content is - // created in the arrange, not the panel). - GuidBaseline contentBaseline; - bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick -}; - -PanelState g_panel; - -void stopAudition(); - -// --- Current-project directory (mirrors persist.cpp's derivation) ------------- -std::string currentProjectDir() { - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - std::string rpp(buf.data()); - if (rpp.empty()) return {}; - return normalizeSlashes(fs::path(rpp).parent_path().string()); -} - -// --- Book / bank accessors ---------------------------------------------------- - -BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } - -// The BankModel a region currently displays. Pool region -> the pool; banks region -> -// the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved -// FRESH every call (never cached across a mutation). -const BankModel* indexForRegion(Region r) { - BankBook* b = book(); - if (!b) return nullptr; - if (r == Region::Pool) return &b->pool().index; - if (g_panel.shownBankId.empty()) return nullptr; - return b->index(g_panel.shownBankId); -} - -// The bank id a region displays (pool id, or the shown tab's id; "" when none). -std::string bankIdForRegion(Region r) { - if (r == Region::Pool) return std::string(kPoolBankId); - return g_panel.shownBankId; -} - -// The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. -std::vector namedBanks() { - std::vector out; - BankBook* b = book(); - if (!b) return out; - for (const Bank& bk : b->banks()) - if (!bk.isPool()) out.push_back(&bk); - return out; -} - -// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- - -Envelope computeThumbnail(const std::string& absPath, int width) { - if (width <= 0 || absPath.empty()) return {}; - - PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); - if (!src) return {}; - - const int nch = src->GetNumChannels(); - const double srate = src->GetSampleRate(); - const double lengthSec = src->GetLength(); - if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { - PCM_Source_Destroy(src); - return {}; - } - - std::int64_t totalFrames = static_cast(lengthSec * srate); - if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } - int frames = totalFrames > kMaxThumbnailFrames - ? kMaxThumbnailFrames - : static_cast(totalFrames); - - std::vector buf(static_cast(frames) * nch, 0.0); - PCM_source_transfer_t block{}; - block.time_s = 0.0; - block.samplerate = srate; - block.nch = nch; - block.length = frames; - block.samples = buf.data(); - block.samples_out = 0; - src->GetSamples(&block); - - PCM_Source_Destroy(src); - - const int got = block.samples_out; - if (got <= 0) return {}; - - const std::size_t sampleCount = static_cast(got) * nch; - std::vector pcm(sampleCount); - for (std::size_t i = 0; i < sampleCount; ++i) - pcm[i] = static_cast(buf[i]); - - // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with - // trailing empty {0,0} bins, which would render a very short sample as a comb of - // spikes over flat gaps. - const int binCount = width < got ? width : got; - return computeEnvelope(pcm, static_cast(nch), - static_cast(got), - static_cast(binCount)); -} - -const Envelope& thumbnailFor(const Sample& sample, int width, - const std::string& projectDir) { - ThumbnailKey key{sample.id, width, g_panel.generation}; - const std::string ks = thumbnailKeyString(key); - - auto it = g_panel.cache.find(ks); - if (it != g_panel.cache.end()) return it->second.envelope; - - const std::string abs = resolveBankFile(projectDir, sample.relativePath); - CachedThumbnail thumb; - thumb.width = width; - thumb.envelope = computeThumbnail(abs, width); - auto ins = g_panel.cache.emplace(ks, std::move(thumb)); - return ins.first->second.envelope; -} - -// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- - -// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT -// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT -// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's -// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical -// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string. -void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { - MusicalLength ml; - ml.lengthSeconds = s.lengthSeconds; - ml.tempoBpm = s.captureTempo; - ml.timeSigNum = s.captureTimeSigNum; - ml.timeSigDenom = s.captureTimeSigDenom; - const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo - const std::string secs = formatSecondsMs(s.lengthSeconds); - - // A short strip along the card's bottom edge. Left/right halves; text/dim so the - // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right - // (tabular numbers that must not jitter). - const int stripH = 12; - const int pad = 3; - const int y = rect.y + rect.height - stripH; - if (!bars.empty()) { - const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH}; - text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left); - } - const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH}; - text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right); -} - -void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, - bool selected, bool focused, bool hovered, const Sample* sample) { - // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL - // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is - // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill- - // state change orthogonal to that border, so a hovered selected card still reads selected. - const KitBox cell{rect.x, rect.y, rect.width, rect.height}; - const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest; - fillSurface(bmp, cell, Role::BgCell, state); - - // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else - // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card - // reads BOTH — the purple outer border + the inner focus ring — kept visually separate. - const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); - if (focused) { - const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); - LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); - } - - // Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column - // min/max envelope draw the VST editor hero + browser cards use — one algorithm, one - // look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per - // column via peaks::columnMinMax inside the kit; an empty env draws just the midline. - drawWaveform(bmp, cell, env); - - // L7 decorative metadata overlay, drawn last so it sits over the waveform. - if (sample) drawCardMeta(bmp, rect, *sample); -} - -// --- Kit draw adapters (Phase L) ---------------------------------------------- -// -// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText -// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces -// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based -// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for -// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle -// (kitFontsInit/Shutdown, wired at panel open/close below). - -KitBox toKitBox(const RECT& r) { - return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; -} - -// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single -// conversion boundary the kit enforces. No local alias needed. - -// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit -// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for -// region headings). Takes a KitBox directly (the pure geometry the L2 modules return). -void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, - Font font, Role role, Align align) { - text(bmp, box, txt, font, role, align); -} - -// --- Mode toggle (D5; relocated to the footer at L4) -------------------------- - -int modeCount() { - if (!g_panel.session) return 0; - return static_cast(g_panel.session->view().modes().size()); -} - -// --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- -// -// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate -// (height 0) when the client is too short to host it above the split body. The WHOLE band -// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent -// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run -// under the menu button (L5 refinement 1). - -// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its -// geometry + the reserve the action_bar leaves for it. -const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, - /*verticalInset=*/3, /*minLeftInset=*/40}; - -ActionBarRect topToolbarRect(int w) { - ActionBarRect s; - s.x = 0; - s.y = 0; - s.width = w; - s.height = kTopToolbarHeight; - return s; -} - -// The band the More button occupies (the whole top toolbar band as a MenuBarRect). -MenuBarRect topMenuBarRect(int w) { - const ActionBarRect bar = topToolbarRect(w); - return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; -} - -// The More button's rect (right-anchored in the top band). Empty when the band is too narrow -// to place it clear of its left inset — the three variants stay reachable via their bindable -// commands (graceful suppression). -MenuButtonRect topMenuButtonRect(int w) { - return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); -} - -// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the -// far-right More button, so the frequent buttons never overlap it. When the More button is -// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a -// degenerate band), which keeps draw and hit-test consistent whether or not the button shows. -ActionBarRect topToolbarActionRect(int w) { - ActionBarRect bar = topToolbarRect(w); - const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); - bar.width -= reserve; - if (bar.width < 0) bar.width = 0; - return bar; -} - -// --- Footer (L4) -------------------------------------------------------------- - -RECT panelFooter(int w, int h) { - RECT rc{}; - rc.left = 0; - rc.right = w; - rc.top = h - kFooterHeight; - rc.bottom = h; - // Keep the footer below the top toolbar; if the client is too short, collapse it. - if (rc.top < kTopToolbarHeight) rc.top = rc.bottom; - return rc; -} - -// The session's live tail setting (default None / 2 s when no session). Single read -// point so draw, wheel-adjust, and the capture read seam all agree on the source. -TailSetting currentTail() { - return g_panel.session ? g_panel.session->tail() : TailSetting{}; -} - -// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client -// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. -FooterBarLayout footerBarLayoutFor(int w, int h) { - const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return FooterBarLayout{}; - const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; - return computeFooterBar(footer, FooterBarSpec{}); -} - -// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves -// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no -// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) -// A display-only tally over the model's public membership map — no model semantics duplicated. -int activeModeMemberCount() { - if (!g_panel.session) return 0; - const ViewModeModel& view = g_panel.session->view(); - const std::string& active = view.activeModeId(); - if (active.empty()) return 0; - int n = 0; - for (const auto& [guid, m] : view.membership().all()) - if (m.modeIds.count(active) != 0) ++n; - return n; -} - -// The prune button's rect within the footer, derived from the client size. SINGLE source -// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate -// or too narrow to place the button clear of the footer-left group / version readout — the -// action stays reachable via its bindable command, so a suppressed button is graceful. Kept -// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two -// groups never overlap). See prune_button.h §Placement contract. -ButtonRect pruneButtonRectFor(int w, int h) { - const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button - const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; - return computePruneButton(footer, PruneButtonSpec{}); -} - -// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] -// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and -// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button -// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. -void drawFooter(LICE_IBitmap* bmp, int w, int h) { - const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return; - - // Footer band + hairline top divider (the base persistent-controls strip). - fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, - InteractionState::Rest); - LICE_Line(bmp, f.left, f.top, f.right, f.top, - toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - - const FooterBarLayout fb = footerBarLayoutFor(w, h); - - // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box - // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The - // active mode's segment carries the accent; others hover-or-rest bg/cell. - if (!fb.toggle.empty() && g_panel.session) { - const ViewModeModel& view = g_panel.session->view(); - const std::vector& modes = view.modes().all(); - const int n = static_cast(modes.size()); - const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; - const std::vector segs = computeSegmentRects(th, n); - const std::string& activeId = view.activeModeId(); - for (int i = 0; i < static_cast(segs.size()); ++i) { - const SegmentRect& s = segs[static_cast(i)]; - const Mode& mode = modes[static_cast(i)]; - const bool active = mode.id == activeId; - const InteractionState state = - active ? InteractionState::Active - : hoverState(g_panel.hovered, HoverKind::ModeSegment, i); - fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state); - LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - const Role tr = active ? Role::BgBase : Role::TextPrimary; - kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(), - Font::Label, tr, Align::Center); - } - } - - // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count - // travels with the toggle"). Passive text, not a control. - if (!fb.count.empty()) { - const int members = activeModeMemberCount(); - const std::string countLabel = - std::to_string(members) + (members == 1 ? " track" : " tracks"); - kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height}, - countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); - } - - // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the - // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel. - if (!fb.tail.empty()) { - const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1); - const std::string label = tailToggleLabel(currentTail()); - const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}}; - drawButton(bmp, box, label.c_str(), state, /*warn=*/false); - } - - // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion() - // renders the configured version string on stable and that string plus "-beta" on beta, - // so a beta panel self-identifies. It sits inside the space footer_bar reserves at the - // right (rightReserve) and clears the prune button (prune_button::rightInset). Dim, - // passive identification (V3). - kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top}, - reasampler::appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right); - - // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control), - // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign, - // frequent) -> right (destructive, rare) per the L4 footer contract. - const ButtonRect pb = pruneButtonRectFor(w, h); - if (!pb.empty()) { - const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); - const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}}; - drawButton(bmp, box, "Prune", state, /*warn=*/true); - } -} - -// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the -// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The -// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). -bool pointInFooter(int x, int y) { - if (!g_panel.hwnd) return false; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); - return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; -} - -// Commits the current tail setting to ext state and marks the active project dirty -// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only -// path that calls SetProjExtState for the tail key — calling it here closes the gap -// where toggle/scroll would dirty the project but the new value was never written. -// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h). -// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way. -// NON-DESTRUCTIVE: touches nothing in the bank/arrange. -void markTailDirty() { - if (g_panel.session) g_panel.session->saveToActiveProject(); - ReaProject* proj = EnumProjects(-1, nullptr, 0); - if (proj) MarkProjectDirty(proj); -} - -// === Task-grouped toolbars (Phase L, L2 + L4) ================================= -// -// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) -// across TWO toolbars, BOTH drawn through the pure action_bar module: -// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the -// two acts the tool exists for (L4 §1); -// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the -// footer, in the space capture/placement vacated (L4 §2). -// Each button is drawn with its action name (Font::Label) and live key binding on a Micro -// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects, -// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query -// + the NamedCommandLookup/Main_OnCommand dispatch live here. -// -// Each button resolves its command id at RUNTIME from the composed named-command string -// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable -// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) -// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the -// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts. - -// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix -// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action -// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is -// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS -// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster -// in its toolbar's cluster order. -// -// L5: the FACE stays short (shortLabel, sized to never overflow the button width); the FULL name -// (fullName) is the hover tooltip content. fullName is sourced from the SAME phrase the action -// was registered with (render_settings' descriptionPhrase for the capture scopes; the literal -// registered phrase otherwise) so the tooltip matches the Actions-list entry exactly — the -// "ReaSampler:" prefix is not stored here (the face/tooltip never show it, per L5 refinement 2). -struct ActionBarRow { - std::string suffix; - std::string shortLabel; - std::string fullName; - ActionCluster cluster = ActionCluster::Capture; - bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true - // for the top bar (its actions are unconditional triggers). -}; - -// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track) -// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then -// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor / -// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) — -// same registered actions, same command-id contract, just a different home. Capture scopes come -// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8 -// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name -// for the hover tooltip. -std::vector topBarRows() { - std::vector rows; - // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; - // the tooltip carries the full descriptionPhrase the action was registered with. - for (const CaptureActionDef& def : captureActionTable()) { - std::string label = def.commandSuffix; - if (label == "CAPTURE_ITEM") label = "Capture Item"; - else if (label == "CAPTURE_TRACK") label = "Capture Track"; - rows.push_back({def.commandSuffix, label, def.descriptionPhrase, - ActionCluster::Capture, true}); - } - // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and - // the placement group so its position reads "refine the last capture before placing it". - // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6). - rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", - "re-capture from source", ActionCluster::Maintenance, true}); - // Placement cluster — the second act (still a distinct on-demand act; no auto-insert). - rows.push_back({"INSERT_SELECTED", "Insert", - "insert selected sample at edit cursor", ActionCluster::Placement, true}); - rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", - "insert selected sample at edit cursor (conform to tempo)", - ActionCluster::Placement, true}); - return rows; -} - -// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the -// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus -// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered -// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The -// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has -// room for the full name). Batch entries first, then the two realtime verbs. -std::vector overflowMenuRows() { - return { - {"CAPTURE_BATCH_ITEMS", "Batch Items", - "batch capture selected items (one per item)", ActionCluster::Capture, true}, - {"CAPTURE_BATCH_RAZOR", "Batch Razor", - "batch capture razor areas (one per area)", ActionCluster::Capture, true}, - {"CAPTURE_TRACK_REALTIME", "Capture RT", - "capture selected track (realtime)", ActionCluster::Capture, true}, - {"CANCEL_REALTIME_CAPTURE", "Cancel RT", - "cancel realtime capture", ActionCluster::Maintenance, true}, - }; -} - -// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for -// "which mode is active"). Empty when no session (every button then falls to fail-open live). -std::string activeModeIdOrEmpty() { - if (!g_panel.session) return {}; - return g_panel.session->view().activeModeId(); -} - -// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons -// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from -// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; -// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not -// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). -// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the -// footer [Arrange|Design] toggle owns mode switching. -// -// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode -// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from -// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw -// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the -// Track button for a target share the target's enablement. -std::vector bottomBarRows() { - const std::string active = activeModeIdOrEmpty(); - const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); - const bool designLive = tagButtonEnabled(active, TagTarget::Design); - - std::vector rows; - // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. - rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", - "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); - rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", - "move selected items -> Design", ActionCluster::Tagging, designLive}); - rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange", - "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); - rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", - "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); - // Switching cluster — Show Both, set apart (the only survivor of the old switching group). - rows.push_back({"VIEW_SHOW_BOTH", "Show Both", - "show both for selected tracks", ActionCluster::Switching, true}); - return rows; -} - -// The cluster button-count specs for a given row set, in the row list's cluster order (so the -// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds; -// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec -// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement — -// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging, -// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there. -std::vector actionBarClusters(const std::vector& rows) { - int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; - for (const ActionBarRow& r : rows) { - switch (r.cluster) { - case ActionCluster::Capture: ++nCap; break; - case ActionCluster::Placement: ++nPlace; break; - case ActionCluster::Maintenance: ++nMaint; break; - case ActionCluster::Tagging: ++nTag; break; - case ActionCluster::Switching: ++nSwitch; break; - } - } - return { - {ActionCluster::Capture, nCap}, - {ActionCluster::Maintenance, nMaint}, - {ActionCluster::Placement, nPlace}, - {ActionCluster::Tagging, nTag}, - {ActionCluster::Switching, nSwitch}, - }; -} - -// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared -// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: -// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6: -// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now. -const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, - /*sidePad=*/8, /*verticalInset=*/3}; - -// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split -// body). Degenerate (height 0) when the client is too short to host it above the footer. -ActionBarRect bottomToolbarRect(int w, int h) { - ActionBarRect s; - const RECT footer = panelFooter(w, h); - const int footerTop = (footer.top < footer.bottom) ? footer.top : h; - s.x = 0; - s.width = w; - s.height = kBottomToolbarHeight; - s.y = footerTop - kBottomToolbarHeight; - // Keep the bar below the top toolbar; if the client is too short, collapse it. - if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } - return s; -} - -// Resolves a row's composed named command to its runtime command id (0 if not registered). -// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). -int resolveBarCommandId(const ActionBarRow& row) { - if (!NamedCommandLookup) return 0; - const std::string named = "_" + channelCommandId(row.suffix); - return NamedCommandLookup(named.c_str()); -} - -// The current key binding string for a command in the MAIN section, or "" (unbound / not -// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). -std::string barBindingText(int cmd) { - if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { - const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); - if (t) return std::string(t); - } - return {}; -} - -// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button -// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face. -// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so -// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use -// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider` -// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body); -// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip -// (L6), not on the button face — the face shows only shortLabel. -void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, - const std::vector& rows, HoverKind hoverKind, bool topDivider) { - if (bar.height <= 0 || bar.width <= 0) return; - - const KitBox band{bar.x, bar.y, bar.width, bar.height}; - fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest); - const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1; - LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY, - toLice(roleColor(Role::LineHairline)), 0.5f, 0, false); - - const std::vector clusters = actionBarClusters(rows); - const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); - - for (const ActionBarSlot& s : slots) { - if (s.index < 0 || s.index >= static_cast(rows.size())) continue; - const ActionBarRow& row = rows[static_cast(s.index)]; - const int cmd = resolveBarCommandId(row); - - // State: Disabled when the action is not registered on this channel OR the row is gated - // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover - // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) - InteractionState state = InteractionState::Rest; - if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; - else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) - state = InteractionState::Hover; - - // The button surface (drawButton draws the micro-gradient + rounded border + honors - // the state). The label is drawn separately so the text role tracks the state correctly; - // pass no label to drawButton. - const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; - drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); - - const Role textRole = - (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary; - const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH}; - kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center); - } -} - -// The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. -int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { - if (bar.height <= 0) return -1; - return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec); -} - -// Routes a click in a toolbar to the hit button's action, fired through the command-id contract -// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click -// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the -// caller stops before grid handling. `rows` is the toolbar's inventory. -bool handleToolbarClick(int x, int y, const ActionBarRect& bar, - const std::vector& rows) { - if (bar.height <= 0) return false; - const int hit = toolbarHit(x, y, bar, rows); - if (hit < 0) { - // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through - // to the grid. Outside the band: not ours. - return y >= bar.y && y < bar.y + bar.height && - x >= bar.x && x < bar.x + bar.width; - } - const ActionBarRow& row = rows[static_cast(hit)]; - // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the - // action and never falls through to the grid (a dead button reads as inert, not absent). - if (!row.enabled) return true; - const int cmd = resolveBarCommandId(row); - if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); - return true; -} - -// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- -// -// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ -// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the -// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand -// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine -// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. - -// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). -void drawMoreButton(LICE_IBitmap* bmp, int w) { - const MenuButtonRect mb = topMenuButtonRect(w); - if (mb.empty()) return; - const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); - const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; - drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); - // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text - // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". - kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", - Font::Label, Role::TextPrimary, Align::Center); -} - -// Opens the More popup: defined later (after the menuAppend/menuSeparator helpers), forward- -// declared here so drawMoreButton's neighbours read together. The click site (handleClick) sits -// after the definition, so no forward-declaration is strictly required — this documents intent. -void showMoreMenu(); - -// --- Tooltip (L5 refinement 2) ------------------------------------------------ -// -// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar -// button. Resolves the hovered element to its (anchor rect, text); returns false when the current -// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its -// affordance). The tooltip DRAW is below; timing (kTooltipDelayMs) is applied by the caller. - -// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its -// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button. -bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) { - const Hover& hv = g_panel.hovered; - std::vector rows; - ActionBarRect bar{}; - if (hv.kind == HoverKind::TopBarButton) { - rows = topBarRows(); - bar = topToolbarActionRect(w); - } else if (hv.kind == HoverKind::BottomBarButton) { - rows = bottomBarRows(); - bar = bottomToolbarRect(w, h); - } else { - return false; - } - if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; - - // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + - // hit-test use, so the anchor matches the drawn button exactly. - const std::vector clusters = actionBarClusters(rows); - const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); - const ActionBarSlot* slot = nullptr; - for (const ActionBarSlot& s : slots) - if (s.index == hv.index) { slot = &s; break; } - if (!slot) return false; - - // The full name is stored already prefix-free, but strip defensively in case a source ever - // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). - // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries - // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the - // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip). - const std::string phrase = stripActionPrefix(rows[static_cast(hv.index)].fullName, - actionDisplayPrefix()); - const int cmd = resolveBarCommandId(rows[static_cast(hv.index)]); - const std::string binding = barBindingText(cmd); - textOut = binding.empty() ? phrase : phrase + " \xe2\x80\x94 " + binding; // " — " (em dash, UTF-8) - ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height; - return true; -} - -// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current -// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the -// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the -// anchor, flipping above near the bottom edge, clamped to the client). -void drawTooltip(LICE_IBitmap* bmp, int w, int h) { - if (!g_panel.tooltipShown) return; - std::string txt; - int ax = 0, ay = 0, aw = 0, ah = 0; - if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return; - - const int textW = static_cast(txt.size()) * kTooltipCharPx; - const TooltipBox tb = - computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{}); - if (tb.empty()) return; - - // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text. - const KitBox box{tb.x, tb.y, tb.width, tb.height}; - fillSurface(bmp, box, Role::BgCell, InteractionState::Hover); - LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); -} - -// --- Split geometry ----------------------------------------------------------- -// -// Every rect below is derived from the client size + fullHeight state, and BOTH paint -// and hit-testing call these so they never drift. All are top-left origin. - -// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the -// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a -// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there. -RECT splitBody(int w, int h) { - RECT rc{}; - rc.left = 0; - rc.right = w; - rc.top = kTopToolbarHeight; - const ActionBarRect bar = bottomToolbarRect(w, h); - rc.bottom = bar.y; - if (rc.bottom < rc.top) rc.bottom = rc.top; - return rc; -} - -// True when both regions are shown (the split is live). Otherwise one region fills -// the body. -bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } -bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } - -// The pool region's rect (whole-region: header band + grid). Empty when hidden. -RECT poolRegionRect(int w, int h) { - const RECT body = splitBody(w, h); - if (!poolShown()) return RECT{0, 0, 0, 0}; - if (!banksShown()) return body; // pool full-height: the whole body - // Split: pool gets the top half (minus the divider). - RECT rc = body; - rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; - if (rc.bottom < rc.top) rc.bottom = rc.top; - return rc; -} - -// The named-banks region's rect (whole-region: header band + tab strip + grid). -RECT banksRegionRect(int w, int h) { - const RECT body = splitBody(w, h); - if (!banksShown()) return RECT{0, 0, 0, 0}; - if (!poolShown()) return body; // banks full-height: the whole body - RECT rc = body; - rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 + - kSplitDividerHeight; - if (rc.top > rc.bottom) rc.top = rc.bottom; - return rc; -} - -// A region's header band (the top kRegionHeaderHeight of the region). -RECT regionHeaderRect(const RECT& region) { - RECT rc = region; - rc.bottom = region.top + kRegionHeaderHeight; - if (rc.bottom > region.bottom) rc.bottom = region.bottom; - return rc; -} - -// The named-banks region's tab strip (below its header band). -TabStripRect banksTabStripRect(const RECT& region) { - const RECT hdr = regionHeaderRect(region); - TabStripRect s; - s.x = region.left; - s.y = hdr.bottom; - s.width = region.right - region.left; - s.height = kTabStripHeight; - if (s.y + s.height > region.bottom) s.height = region.bottom - s.y; - if (s.height < 0) s.height = 0; - return s; -} - -// A region's grid viewport (below the header band, and below the tab strip for the -// banks region). This is where cells tile. -RECT regionGridRect(const RECT& region, bool isBanks) { - RECT rc = region; - rc.top = region.top + kRegionHeaderHeight; - if (isBanks) rc.top += kTabStripHeight; - if (rc.top > rc.bottom) rc.top = rc.bottom; - return rc; -} - -// The full-height toggle button rect inside a region header (right-aligned). -RECT fullHtBtnRect(const RECT& region) { - const RECT hdr = regionHeaderRect(region); - RECT rc = hdr; - rc.right = hdr.right - 4; - rc.left = rc.right - kFullHtBtnWidth; - rc.top = hdr.top + 2; - rc.bottom = hdr.bottom - 2; - return rc; -} - -// The "+" create-bank button rect inside the named-banks region header (left of the -// full-height button). -RECT createBtnRect(const RECT& region) { - RECT ft = fullHtBtnRect(region); - RECT rc = ft; - rc.right = ft.left - 4; - rc.left = rc.right - kCreateBtnWidth; - return rc; -} - -// --- L7 slot-order display bridge --------------------------------------------- -// -// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted -// SlotMap order (sparse, gap-preserving), NOT BankModel insertion order. This one helper -// is the single place that resolves a region's display, composed purely from bank_book's -// slot order (orderedSampleIds) + card_drag's sparse slot rects (computeSlotRects) — the -// shell adds no layout math of its own. -// -// TWO INDEX SPACES the whole panel must keep straight: -// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty -// cells and are valid drop targets. This is what pixels/hit-tests speak. -// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / -// applyClick / navigate reason in. Selection index i <-> orderedIds[i]. -// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS -// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). -// RegionDisplay carries both plus the translation between them, resolved FRESH each call -// (never cached across a mutation, per the reference-invalidation guardrail). -struct RegionDisplay { - std::vector orderedIds; // occupied ids in slot order (selection space) - std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords - const Bank* bank = nullptr; - - // The id occupying `slot`, or "" for an empty slot / out of range. - std::string idAtSlot(int slot) const { - return bank ? bank->slots.idAt(slot) : std::string{}; - } - // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot. - int slotForSelection(int sel) const { - if (sel < 0 || sel >= static_cast(orderedIds.size()) || !bank) return -1; - return bank->slots.slotOf(orderedIds[static_cast(sel)]); - } - // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when - // the slot is empty. Inverse of slotForSelection. - int selectionForSlot(int slot) const { - const std::string id = idAtSlot(slot); - if (id.empty()) return -1; - for (std::size_t i = 0; i < orderedIds.size(); ++i) - if (orderedIds[i] == id) return static_cast(i); - return -1; - } - int occupiedCount() const { return static_cast(orderedIds.size()); } -}; - -// Resolves a region's display for the currently-shown bank. Empty (no bank / no width) -// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live -// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete -// order (trailing empties are trimmed by the model — maxSlot walks only live occupants). -RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { - RegionDisplay d; - BankBook* b = book(); - if (!b) return d; - const std::string bankId = bankIdForRegion(reg); - if (bankId.empty()) return d; - d.bank = b->bank(bankId); - if (!d.bank) return d; - - d.orderedIds = b->orderedSampleIds(bankId); // occupied ids, slot order (reconciles) - if (d.orderedIds.empty()) return d; - - const RECT grid = regionGridRect(region, isBanks); - const int w = grid.right - grid.left; - if (w <= 0) return d; - d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid); - for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; } - return d; -} - -// The FOCUSED region's display (the slot-order bridge for the region holding the live -// selection). Mirrors columnsForRegion's client read. -RegionDisplay focusedDisplay() { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const bool isBanks = g_panel.focusedRegion == Region::Banks; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - return regionDisplay(region, isBanks, g_panel.focusedRegion); -} - -// --- Drawing: a grid region --------------------------------------------------- - -// Draws one region's grid of thumbnails (or an empty-state line) clipped to its -// viewport. `selectionOwner` is true when this region holds the live selection, so -// its cells show selection/focus chrome; the other region draws plain. -void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, - const BankModel* index, const std::string& emptyMsg, - bool selectionOwner, const std::string& projectDir, Region reg) { - const RECT grid = regionGridRect(region, isBanks); - if (grid.bottom <= grid.top) return; - - if (!index || index->empty()) { - kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center); - return; - } - - // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense - // BankModel insertion order. Selection/focus are keyed by the occupied-ordinal (selection - // space); a slot maps back to its ordinal via selectionForSlot. - const RegionDisplay disp = regionDisplay(region, isBanks, reg); - // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's - // peaks::columnMinMax exact partition makes every column gap-free — overbinning - // produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps - // the request to the frame count. - const int binWidth = kWaveformOversample * - waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight}); - for (const SlotCellRect& r : disp.slotRects) { - if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll) - const CellRect rect{r.x, r.y, r.width, r.height}; - const std::string id = disp.idAtSlot(r.slot); - if (id.empty()) { - // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline - // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No - // selection/focus/waveform, and not a hover or hit target (the grid never tracks - // cell hover; a click on an empty slot clears selection like any grid miss). - fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, - Role::BgCell, InteractionState::Rest); - LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - continue; - } - const Sample* s = index->query(id); - if (!s) continue; // reconciled order should never name a stale id; defensive - const int sel = disp.selectionForSlot(r.slot); - const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel); - const bool focused = selectionOwner && sel >= 0 && g_panel.selection.focus == sel; - const Envelope& env = thumbnailFor(*s, binWidth, projectDir); - // Grid-cell hover is intentionally not tracked: the cell already carries selection + - // focus chrome (the centerpiece's "bones"); a third transient hover state on every - // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs. - drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, s); - } -} - -// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but -// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag -// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection -// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline -// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op -// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). -void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { - if (!g_panel.dragging) return; - if (g_panel.cardGesture != CardGesture::Reorder && - g_panel.cardGesture != CardGesture::Replace) - return; - if (g_panel.dragSourceRegion != reg) return; // highlight only the source bank's grid - if (g_panel.dragTargetSlot < 0) return; - - const RECT grid = regionGridRect(region, isBanks); - const RegionDisplay disp = regionDisplay(region, isBanks, reg); - // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent - // target slot gets a visible highlight cue, not silence. - const int gridW = grid.right - grid.left; - const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1; - std::vector dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid); - for (SlotCellRect& r : dropRects) { r.x += grid.left; r.y += grid.top; } - for (const SlotCellRect& r : dropRects) { - if (r.slot != g_panel.dragTargetSlot) continue; - if (r.y >= grid.bottom) return; // below the viewport (no scroll) - const LICE_pixel hot = toLice(roleColor(Role::AccentHot)); - LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0); - if (g_panel.cardGesture == CardGesture::Replace) - LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0); - return; - } -} - -// Draws a region header: title, the active-bank readout, and the full-height button. -void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, - const std::string& activeName, bool poolBtnIsPool) { - const RECT hdr = regionHeaderRect(region); - // Region header band (kit bg/panel — a raised region title bar). A hairline underline. - fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, - Role::BgPanel, InteractionState::Rest); - LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, - toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - - // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of - // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary - // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is - // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:" - // readout beside it), keeping primary reserved for the live/active layer. - RECT titleRc = hdr; - titleRc.left += 8; - titleRc.right = titleRc.left + 120; - const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; - kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); - - // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY - // accent role in BOTH region headers so the active/capture-target bank is legible even when - // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). - const std::string readout = "Active: " + activeName; - RECT actRc = hdr; - actRc.left = titleRc.right + 6; - actRc.right = createBtnRect(region).left - 6; - if (actRc.right > actRc.left) - kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); - - // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; - // when this region is already full it means "restore the split". Kit drawButton + hover. - const RECT btn = fullHtBtnRect(region); - const bool thisFull = - poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) - : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); - const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks; - const InteractionState state = - thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1); - const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left, - btn.bottom - btn.top}}; - drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false); -} - -// Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN -// tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow -// chevrons when present, plus the "+" create button in the header. During a drag, -// the tab under the pointer gets the drop-target highlight. -void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { - const TabStripRect strip = banksTabStripRect(region); - if (strip.height <= 0) return; - // Tab strip band (kit bg/base — recessed relative to the region header above it). - fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, - Role::BgBase, InteractionState::Rest); - - const std::vector tabs = namedBanks(); - const int n = static_cast(tabs.size()); - if (n == 0) { - kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height}, - "No named banks -- click + to create one.", - Font::Label, Role::TextDim, Align::Left); - return; - } - - const TabStripLayout layout = - computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); - - // Chevrons (drawn first so tabs sit above their inner edges). - if (layout.overflow) { - const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height}; - const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, - kTabSpec.chevronWidth, strip.height}; - fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest); - fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest); - kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center); - kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center); - } - - const std::string activeId = book() ? book()->activeBankId() : std::string(); - const std::vector rects = - computeTabRects(strip, n, kTabSpec, g_panel.tabScroll); - for (const TabRect& tr : rects) { - const Bank* bk = tabs[static_cast(tr.index)]; - const bool shown = bk->id == g_panel.shownBankId; - const bool active = bk->id == activeId; - const bool dropHere = g_panel.dragging && - g_panel.dropKind == DropKind::Tab && - g_panel.dropBankId == bk->id; - const bool hovered = g_panel.hovered.kind == HoverKind::Tab && - g_panel.hovered.index == tr.index; - - // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag - // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); - // else hover-or-rest bg/cell. - const KitBox tb{tr.x, tr.y, tr.width, tr.height}; - InteractionState state = InteractionState::Rest; - if (active) state = InteractionState::Active; - else if (dropHere) state = InteractionState::Dragging; - else if (shown) state = InteractionState::Pressed; - else if (hovered) state = InteractionState::Hover; - fillSurface(bmp, tb, Role::BgCell, state); - - // The active bank's tab gets a bright accent border (unmistakable), distinct from the - // shown tab's fill — active != shown, made visible (kit accent role). - const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0); - if (active) - LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, - toLice(border), 1.0f, 0); - - // Label: bg/base on the accent-active fill for contrast, else text/primary. - const Role trole = active ? Role::BgBase : Role::TextPrimary; - kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height}, - bk->displayName.c_str(), Font::Label, trole, Align::Center); - } -} - -// The active bank's display name (for the readout). "Pool" when the pool is active. -std::string activeBankName() { - BankBook* b = book(); - if (!b) return std::string(kPoolBankName); - const Bank* bk = b->bank(b->activeBankId()); - return bk ? bk->displayName : std::string(kPoolBankName); -} - -// --- Full paint --------------------------------------------------------------- - -void paintPanel(HWND hwnd, HDC hdc) { - RECT cr{}; - GetClientRect(hwnd, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - if (w <= 0 || h <= 0) return; - - LICE_SysBitmap bmp(w, h); - LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); - - const std::string projectDir = currentProjectDir(); - const std::string activeName = activeBankName(); - - // Pool region (top). - if (poolShown()) { - const RECT region = poolRegionRect(w, h); - drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); - drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), - "No samples in the pool yet. Capture one to see it here.", - g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); - // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid - // outline signalling "drop here to move/copy into this bank"). Suppressed for a - // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). - if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && - (g_panel.cardGesture == CardGesture::Move || - g_panel.cardGesture == CardGesture::Copy)) { - const RECT grid = regionGridRect(region, false); - LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, - grid.right - grid.left - 2, grid.bottom - grid.top - 2, - toLice(roleColor(Role::AccentHot)), 1.0f, 0); - } - // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline - // on the target slot's cell — distinct from the accent/tertiary purple selection - // border, so it is never confusable with a selected card. - drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); - } - - // Split divider. - if (poolShown() && banksShown()) { - const RECT body = splitBody(w, h); - const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; - LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, - toLice(roleColor(Role::BgBase)), 1.0f, 0); - } - - // Named-banks region (bottom). - if (banksShown()) { - const RECT region = banksRegionRect(w, h); - drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); - // "+" create button (drawn as part of the banks header) — kit drawButton + hover. - const RECT cbtn = createBtnRect(region); - const InteractionState createState = - hoverState(g_panel.hovered, HoverKind::CreateBank, -1); - drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left, - cbtn.bottom - cbtn.top}}, - "+", createState, /*warn=*/false); - - drawTabStrip(&bmp, region); - drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), - g_panel.shownBankId.empty() - ? "Select or create a named bank." - : "This bank is empty. Move samples here from the pool.", - g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); - // Drop-target highlight for the banks region during a drag. BanksRegion fires - // when the pointer is in the grid but not on a specific tab; Tab draws its own - // highlight on the individual tab (drawTabStrip above handles that case). - if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && - (g_panel.cardGesture == CardGesture::Move || - g_panel.cardGesture == CardGesture::Copy)) { - const RECT grid = regionGridRect(region, true); - LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, - grid.right - grid.left - 2, grid.bottom - grid.top - 2, - toLice(roleColor(Role::AccentHot)), 1.0f, 0); - } - // L7 per-slot reorder/replace target highlight (source = banks region). - drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); - } - - // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles - // into the band MINUS the far-right More-button reserve; the More button is drawn over the - // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show - // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit - // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the - // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. - fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); - drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, - /*topDivider=*/false); - drawMoreButton(&bmp, w); - drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton, - /*topDivider=*/true); - drawFooter(&bmp, w, h); - - // The custom hover-delay tooltip overlays everything (L5 refinement 2). - drawTooltip(&bmp, w, h); - - BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); -} - -// --- Bank-change detection ---------------------------------------------------- - -// A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag -// + per-sample id/path. Catches every mutation the panel must redraw for: capture, -// project load, and B4's own create/rename/delete/move/activate. -std::string bookFingerprint() { - BankBook* b = book(); - if (!b) return {}; - std::string fp = std::to_string(b->size()); - fp += '\x1e'; fp += b->activeBankId(); - for (const Bank& bk : b->banks()) { - fp += '\x1d'; - fp += bk.id; - fp += '\x1c'; - fp += bk.displayName; - for (const Sample& s : bk.index.all()) { - fp += '\x1f'; - fp += s.id; - fp += '\x1f'; - fp += s.relativePath; - } - } - return fp; -} - -// Reconciles shownBankId against the live named banks: keep it if it still names a -// named bank; otherwise fall to the first named bank (or empty when none). Keeps the -// banks region always showing a valid tab. Never touches the ACTIVE bank. -void reconcileShownBank() { - BankBook* b = book(); - if (!b) { g_panel.shownBankId.clear(); return; } - if (!g_panel.shownBankId.empty()) { - const Bank* bk = b->bank(g_panel.shownBankId); - if (bk && !bk->isPool()) return; // still valid - } - const std::vector named = namedBanks(); - g_panel.shownBankId = named.empty() ? std::string() : named.front()->id; -} - -bool refreshFingerprint() { - if (!book()) return false; - std::string fp = bookFingerprint(); - if (fp == g_panel.bankFingerprint) return false; - g_panel.bankFingerprint = std::move(fp); - ++g_panel.generation; - g_panel.cache.clear(); - // The selection indexes into the OLD order; a change can invalidate those, so - // clear it and stop any audition. - if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { - g_panel.selection = Selection{}; - stopAudition(); - } - reconcileShownBank(); - const BankModel* idx = indexForRegion(g_panel.focusedRegion); - g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; - return true; -} - -// --- New-content detection (D2 Wave 2) ---------------------------------------- -// -// REAPER exposes no "item/track added" callback, so we diff live project state on the -// existing timer. Each tick: enumerate every track GUID and every item GUID, diff -// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new -// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane -// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All -// enumeration is READ-ONLY on the project; the only mutation is to the in-memory -// membership index (persisted by persist on the next save, same as an action-driven tag). - -// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified -// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so -// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace. -constexpr int kFreeModeFixedLanes = 2; - -bool isFixedLaneTrack(MediaTrack* tr) { - return static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; -} - -// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): -// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies. - -// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, -// baseline input) and, for each item, records whether it sits on a manual lane so a -// newly-detected item can be exempted from auto-tag without a second project walk. -// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a -// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand -// guard) without a second project walk. -// -// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack, -// laneName) from lane_keys — the same predicate the apply path consults — so the exemption -// rule is defined in exactly one place and is unit-tested there. -void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, - std::map& itemOnManualLane, - std::map>& trackItemGuids) { - const int trackCount = CountTracks(proj); - for (int t = 0; t < trackCount; ++t) { - MediaTrack* tr = GetTrack(proj, t); - if (!tr) continue; - std::string tg = guidString(tr); - if (!tg.empty()) allGuids.insert(tg); - - // Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a - // track-level attribute and is the same for every item on the track. - const bool fixedLane = isFixedLaneTrack(tr); - - std::vector& itemsOnTrack = trackItemGuids[tg]; - const int itemCount = CountTrackMediaItems(tr); - for (int i = 0; i < itemCount; ++i) { - MediaItem* it = GetTrackMediaItem(tr, i); - if (!it) continue; - std::string ig = itemGuid(it); - if (ig.empty()) continue; - allGuids.insert(ig); - // Classify via the single shared predicate. For a fixed-lane track we read - // the item's lane name; for a normal track we pass "" (isOnManualLane returns - // false immediately for non-fixed-lane tracks regardless of name). - const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; - itemOnManualLane[ig] = isOnManualLane(fixedLane, ln); - itemsOnTrack.push_back(ig); - } - } -} - -// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones -// into the active mode. Runs every timer tick regardless of panel open/close (content -// is created in the arrange). READ-ONLY on the project; mutates only the in-memory -// membership index. -// -// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a -// background metadata update (like setting a label), not a destructive project edit. -// persist.cpp writes it on the next project save alongside the bank and view state, the -// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood -// the REAPER undo history with a new entry for every timer tick that sees new content. -// Returns true iff this tick tagged at least one new GUID into a mode — the signal the -// caller uses to decide whether to run the lane-minting pass (a track can only newly -// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint. -bool detectNewContent() { - if (!g_panel.session) return false; - - ReaProject* proj = EnumProjects(-1, nullptr, 0); - - // A project (re)load re-arms the first-poll guard so we never diff across two - // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded() - // on the tick persist restores the project's membership + active mode, which sets - // reloadPending. Draining it here re-baselines against the fully-loaded set (that - // same tick's reapply-active-mode enumerated those tracks, so they are present), - // and the observe() below returns nothing new — pre-existing untagged tracks stay - // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so - // no separate first-tick handling is needed here. Using persist's GUID-primary load - // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two - // identity checks can no longer diverge on a recycled ReaProject* address. - if (g_panel.reloadPending) { - g_panel.contentBaseline.reset(); - g_panel.reloadPending = false; - } - - std::set live; - std::map itemOnManualLane; - std::map> trackItemGuids; - enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); - - const std::vector added = g_panel.contentBaseline.observe(live); - if (added.empty()) return false; // first poll after open, or nothing new this tick - - ViewModeModel& model = g_panel.session->view(); - - // Which of `added` are items (the manual-lane map keys every item; track GUIDs never - // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING - // mode set — a drop plus its own new siblings must not count each other as prior. - const std::set newItemGuids = [&] { - std::set s; - for (const std::string& g : added) - if (itemOnManualLane.count(g)) s.insert(g); - return s; - }(); - - // Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings - // are found in one lookup. - std::map trackOfItem; - for (const auto& [trackGuid, items] : trackItemGuids) - for (const std::string& ig : items) trackOfItem[ig] = trackGuid; - - // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on - // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's - // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as - // planLaneMinting ignores them when computing a track's own-item mode span, so the - // adoption guard's view of the track matches the split decision's. Drives the adoption - // / strand guard in autoTagNewContent. - const auto preExistingTrackModes = - [&](const std::string& trackGuid) -> std::set { - std::set modes; - auto it = trackItemGuids.find(trackGuid); - if (it == trackItemGuids.end()) return modes; - for (const std::string& sib : it->second) { - if (newItemGuids.count(sib)) continue; // a sibling added THIS tick — not prior - auto ml = itemOnManualLane.find(sib); - if (ml != itemOnManualLane.end() && ml->second) continue; // manual lane — exempt - const std::set m = model.membership().modesOf(sib); - if (m.empty()) modes.insert(kArrangeModeId); // untagged ⇒ Arrange default - else modes.insert(m.begin(), m.end()); - } - return modes; - }; - - // Split the new GUIDs into tracks vs items so the pure decision can apply the - // manual-lane exemption to items only. A GUID present in the item-lane map is an - // item; otherwise it is a track (track GUIDs never appear in that map). - std::vector newTracks; - std::vector newItems; - for (const std::string& g : added) { - auto it = itemOnManualLane.find(g); - if (it == itemOnManualLane.end()) { - newTracks.push_back(g); // a track GUID - } else { - NewItem ni{g, it->second, {}}; - auto tk = trackOfItem.find(g); - if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second); - newItems.push_back(std::move(ni)); // an item; carries exemption + track modes - } - } - - const std::vector tags = - autoTagNewContent(newTracks, newItems, model.activeModeId()); - for (const AutoTag& tag : tags) - model.membership().tag(tag.guid, tag.modeId); - return !tags.empty(); -} - -// --- Audition preview --------------------------------------------------------- -// -// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW -// playback only. It NEVER inserts into the arrange, creates items/tracks, or -// mutates the project or bank. PlayPreview streams a caller-owned PCM_source -// through REAPER's preview bus and touches nothing in the project. -// -// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by -// signature/struct, not semantics — DAW-verify): -// 1. REAPER's audio thread reads the preview_register_t by POINTER while the -// preview is active (the struct's own comment mandates a cs/mutex we init), -// so the register must outlive playback — we hold it in g_panel (static), -// never on the stack. -// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it -// returns, making it safe to PCM_Source_Destroy the source immediately after. -// This is the conventional contract (SWS' preview helpers rely on it) but is -// NOT documented in the header — flagged. If a rare race surfaced, the fix is -// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). -// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not -// set mono (&1024). volume 1.0, loop false, curpos 0. - -void initPreview() { - if (g_panel.previewInited) return; -#ifdef _WIN32 - InitializeCriticalSection(&g_panel.preview.cs); -#else - pthread_mutex_init(&g_panel.preview.mutex, nullptr); -#endif - g_panel.previewInited = true; -} - -void stopAudition() { - if (g_panel.previewActive) { - StopPreview(&g_panel.preview); - g_panel.previewActive = false; - } - if (g_panel.previewSrc) { - PCM_Source_Destroy(g_panel.previewSrc); - g_panel.previewSrc = nullptr; - } - g_panel.preview.src = nullptr; -} - -void deinitPreview() { - if (!g_panel.previewInited) return; -#ifdef _WIN32 - DeleteCriticalSection(&g_panel.preview.cs); -#else - pthread_mutex_destroy(&g_panel.preview.mutex); -#endif - g_panel.previewInited = false; -} - -// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. -// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw -// BankModel position. -void startAudition(int idx) { - stopAudition(); - - const BankModel* index = indexForRegion(g_panel.focusedRegion); - if (!index) return; - const RegionDisplay disp = focusedDisplay(); - if (idx < 0 || idx >= disp.occupiedCount()) return; - const Sample* s = index->query(disp.orderedIds[static_cast(idx)]); - if (!s) return; - - const std::string projectDir = currentProjectDir(); - const std::string abs = resolveBankFile(projectDir, s->relativePath); - if (abs.empty()) return; - - PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); - if (!src) return; - - g_panel.preview.src = src; - g_panel.preview.m_out_chan = 0; - g_panel.preview.curpos = 0.0; - g_panel.preview.loop = false; - g_panel.preview.volume = 1.0; - g_panel.preview.peakvol[0] = 0.0; - g_panel.preview.peakvol[1] = 0.0; - g_panel.preview.preview_track = nullptr; - - if (PlayPreview(&g_panel.preview) != 0) { - g_panel.previewSrc = src; - g_panel.previewActive = true; - } else { - PCM_Source_Destroy(src); - g_panel.preview.src = nullptr; - } -} - -// --- Input helpers ------------------------------------------------------------ - -bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } -bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } -bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } // Alt = replace modifier (L7) - -void invalidatePanel() { - if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -// The item count the SELECTION reasons over — the focused region's occupied-cell count. -// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not -// selectable). Occupied count == index size by construction: every index member maps to -// exactly one occupied slot (gaps are empty slots, which the index never backs), so the -// raw index size IS the dense selection-space extent. -int focusedItemCount() { - const BankModel* idx = indexForRegion(g_panel.focusedRegion); - return idx ? static_cast(idx->size()) : 0; -} - -// Which region (if any) contains client point (x, y); returns false via `out` set to -// Pool by default when the point is in neither region body. -bool regionAt(int x, int y, Region& out) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - if (poolShown()) { - const RECT r = poolRegionRect(w, h); - if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { - out = Region::Pool; return true; - } - } - if (banksShown()) { - const RECT r = banksRegionRect(w, h); - if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { - out = Region::Banks; return true; - } - } - return false; -} - -// --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- -// -// Each op mutates g_session.book() then persists via persistBankOp(). After a -// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we -// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an -// unsaved project the empty-close discard in persistBankOp ensures no stale state -// survives (matches the capture/B3 quiet-persist idiom). - -// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { - std::vector buf(512, '\0'); - std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); - const std::string captions = std::string(caption) + ",separator=\x1f"; - if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), - static_cast(buf.size()))) - return false; - std::string s(buf.data()); - if (s.empty()) return false; - out = std::move(s); - return true; -} - -// Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; - guidToString(&g, buf); - return std::string(buf); -} - -void doCreateBank() { - if (!book()) return; - std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = mintBankId(); - if (!book()->createBank(id, name)) { - ShowMessageBox("A bank with that name already exists.", - "ReaSampler: create bank", 0); - return; - } - g_panel.shownBankId = id; // show the freshly-created bank - g_panel.focusedRegion = Region::Banks; - persistBankOp("ReaSampler: create bank"); - invalidatePanel(); -} - -void doRenameBank(const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - const std::string current = bk->displayName; // copy before any mutation - std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; - if (!book()->renameBank(bankId, newName)) { - ShowMessageBox("Another bank already uses that name.", - "ReaSampler: rename bank", 0); - return; - } - persistBankOp("ReaSampler: rename bank"); - invalidatePanel(); -} - -// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the -// member count AND offers evacuate as the one-click alternative (Yes=delete anyway, -// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. -void doDeleteBank(const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - const std::size_t members = bk->index.size(); // read BEFORE any mutation - const std::string name = bk->displayName; - - if (members > 0) { - const std::string msg = - "\"" + name + "\" holds " + std::to_string(members) + - (members == 1 ? " sample" : " samples") + - ".\n\nYes -- delete the bank AND drop its samples (files are kept on disk " - "but no bank references them until prune).\nNo -- Evacuate them to the " - "pool first, then delete the empty bank (keeps the samples).\nCancel -- " - "do nothing."; - // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). - const int r = ShowMessageBox(msg.c_str(), - "ReaSampler: delete non-empty bank", 3); - if (r == 2) return; // Cancel - if (r == 7) { // No -> evacuate, then delete empty - if (!book()->evacuate(bankId)) return; - // book() may have reallocated; re-resolve nothing (we pass the id again). - } - // r == 6 (Yes) falls through to a plain delete (drops members). - } - if (!book()->deleteBank(bankId)) return; - // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- - // delete path moved/dropped members) — both change what a live instance could play. An - // empty-bank delete is purely organizational, no bump. - persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); - // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, - // nudge focus to the pool so the selection has a valid home. - if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; - invalidatePanel(); -} - -void doEvacuateBank(const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - if (!book()->evacuate(bankId)) return; - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed - invalidatePanel(); -} - -void doActivateBank(const std::string& bankId) { - if (!book()) return; - if (!book()->setActiveBank(bankId)) return; // rejects an unknown id - persistBankOp("ReaSampler: activate bank"); - invalidatePanel(); -} - -// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass -// ids straight to the model op (no BankModel& cached across the loop's mutations). -// -// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): -// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally -// before the dest add collapses on hash), so the index DID mutate — counts. -// * COPY collapse: the source is left intact AND the dest already held the hash, -// so NOTHING changed — a true index no-op. Must NOT open an undo point. -// Hence: copy counts only real gains (Copied); move counts gains OR collapses. -void transferSamples(const std::vector& sampleIds, - const std::string& srcBankId, const std::string& destBankId, - bool copy) { - if (!book()) return; - if (sampleIds.empty() || srcBankId == destBankId) return; - if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; - int ok = 0, collapsed = 0; - for (const std::string& sid : sampleIds) { - const TransferResult r = - copy ? book()->copySample(sid, srcBankId, destBankId) - : book()->moveSample(sid, srcBankId, destBankId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedSameBank: break; - } - } - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (!mutated) return; // nothing changed — no persist, no undo point - - const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; - persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed - // The selection indexed into the source; after a move those indices are stale, so - // clear it (the fingerprint pass will also clear, but do it now for immediacy). - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to -// the file: a last-reference remove leaves the file on disk, orphaned until Phase R -// prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent -// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — -// one Ctrl-Z restores the index entry. Ids passed by value — no BankModel& cached -// across the loop's mutations. -void removeSamples(const std::vector& sampleIds, - const std::string& srcBankId) { - if (!book() || sampleIds.empty()) return; - if (!book()->bank(srcBankId)) return; - - int removed = 0; - for (const std::string& sid : sampleIds) - if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - if (removed == 0) return; // nothing changed — no persist, no undo point - - persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped - // The selection indexed into the source; after a remove those indices are stale, so - // clear it (the fingerprint pass will also clear, but do it now for immediacy). - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// The selection's sample ids resolved against the FOCUSED region's bank (source of a -// move/copy). Returns ids in bank order; empty when nothing selected. -std::vector focusedSelectionIds() { - // L7: selection ordinals index the DISPLAY (slot) order, not BankModel insertion order. - // orderedIds[i] is the id at selection ordinal i. - std::vector ids; - const RegionDisplay disp = focusedDisplay(); - const int count = disp.occupiedCount(); - for (int i : g_panel.selection.indices) - if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); - return ids; -} - -// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to -// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4 -// path machinery the panel uses for audition/insert (resolveBankFile over the current -// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each -// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region, -// which can differ once the pointer roams), resolved, stat'd, then handed to the pure -// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no -// mutation of sample / index / selection (invariant #2). -std::vector resolveDragPathsForOs() { - std::vector resolved; - BankBook* b = book(); - if (!b) return {}; - const BankModel* idx = b->index(g_panel.dragSourceBankId); - if (!idx) return {}; - - const std::string projectDir = currentProjectDir(); - resolved.reserve(g_panel.dragSampleIds.size()); - for (const std::string& sid : g_panel.dragSampleIds) { - const Sample* s = idx->query(sid); - if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve - ResolvedSample rs; - rs.absolutePath = resolveBankFile(projectDir, s->relativePath); - rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath)); - resolved.push_back(std::move(rs)); - } - return assemblePathList(resolved).paths; -} - -// --- Popup menus -------------------------------------------------------------- -// -// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu -// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of -// (label -> small int command), track it at screen coords, and switch on the return. -// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD -// hands the chosen id straight back, so no hookcommand routing is involved. - -// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both -// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative -// position appending. Win32 and SWELL both treat pos < 0 as an append. -void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { - UINT flags = MF_BYPOSITION | MF_STRING; - if (grayed) flags |= MF_GRAYED; - InsertMenu(menu, -1, flags, id, text); -} -void menuSeparator(HMENU menu) { - InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); -} - -// Menu command ids (local to a popup). -enum : unsigned int { - kMenuNone = 0, - kMenuActivate = 100, - kMenuRename, - kMenuDelete, - kMenuEvacuate, - kMenuCreate, - kMenuRemove, // remove selected sample(s) from the source bank (B5) - kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index - kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index -}; - -// Shows the right-click context menu for a named-bank TAB: activate / rename / delete -// / evacuate that bank, plus a create entry. Drives the id-keyed ops. -void showTabMenu(int screenX, int screenY, const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - const bool isActive = book()->activeBankId() == bankId; - const bool nonEmpty = !bk->index.empty(); - - HMENU menu = CreatePopupMenu(); - menuAppend(menu, kMenuActivate, - isActive ? "Active (capture target)" : "Activate (make capture target)", - /*grayed=*/isActive); - menuSeparator(menu); - menuAppend(menu, kMenuRename, "Rename..."); - menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); - menuAppend(menu, kMenuDelete, "Delete..."); - menuSeparator(menu); - menuAppend(menu, kMenuCreate, "New bank..."); - - const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, - g_panel.hwnd, nullptr); - DestroyMenu(menu); - - switch (cmd) { - case kMenuActivate: doActivateBank(bankId); break; - case kMenuRename: doRenameBank(bankId); break; - case kMenuEvacuate: doEvacuateBank(bankId); break; - case kMenuDelete: doDeleteBank(bankId); break; - case kMenuCreate: doCreateBank(); break; - default: break; - } -} - -// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the -// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup -// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we -// resolve + fire the corresponding registered command id via the SAME contract the visible -// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. -void showMoreMenu() { - if (!g_panel.hwnd) return; - const std::vector rows = overflowMenuRows(); - if (rows.empty()) return; - - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); - if (mb.empty()) return; - - HMENU menu = CreatePopupMenu(); - for (std::size_t i = 0; i < rows.size(); ++i) { - const int cmd = resolveBarCommandId(rows[i]); - // Grey a variant not registered on this channel (defensive — all three are registered). - menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), - /*grayed=*/cmd == 0); - } - - // Anchor the popup at the button's bottom-left, in screen coords. - POINT pt{mb.x, mb.y + mb.height}; - ClientToScreen(g_panel.hwnd, &pt); - const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); - DestroyMenu(menu); - - if (chosen >= 1 && chosen <= static_cast(rows.size())) { - const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); - if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); - } -} - -// Shows the move/copy menu for the current selection (the SOURCE is the focused -// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a -// copy submenu-free flat list (copy entries follow the move block). Move is the -// default (listed first); copy is the deliberate secondary act. -void showSelectionMenu(int screenX, int screenY) { - const std::vector sel = focusedSelectionIds(); - if (sel.empty()) return; - const std::string srcId = bankIdForRegion(g_panel.focusedRegion); - - // Destinations: pool + named banks, excluding the source. Ordinal order. - struct Dest { std::string id; std::string name; }; - std::vector dests; - if (srcId != std::string(kPoolBankId)) - dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); - for (const Bank* bk : namedBanks()) - if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); - - const std::string label = std::to_string(sel.size()) + - (sel.size() == 1 ? " sample" : " samples"); - - HMENU menu = CreatePopupMenu(); - // Move/copy blocks appear only when there is another bank to transfer to; Remove is - // always offered (it needs no destination — it drops the entry from the source). - if (!dests.empty()) { - menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); - for (std::size_t i = 0; i < dests.size(); ++i) - menuAppend(menu, kMenuMoveBase + static_cast(i), - (" " + dests[i].name).c_str()); - menuSeparator(menu); - menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); - for (std::size_t i = 0; i < dests.size(); ++i) - menuAppend(menu, kMenuCopyBase + static_cast(i), - (" " + dests[i].name).c_str()); - menuSeparator(menu); - } - menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str()); - - const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, - g_panel.hwnd, nullptr); - DestroyMenu(menu); - if (cmd == static_cast(kMenuRemove)) { - removeSamples(sel, srcId); - } else if (cmd >= static_cast(kMenuMoveBase) && - cmd < static_cast(kMenuMoveBase + dests.size())) { - transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); - } else if (cmd >= static_cast(kMenuCopyBase) && - cmd < static_cast(kMenuCopyBase + dests.size())) { - transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); - } -} - -// --- Click routing ------------------------------------------------------------ - -// Handles a header/tab-strip/button click for the banks region. Returns true if the -// click was consumed (a region-chrome hit), false to fall through to grid selection. -bool handleBanksChromeClick(int x, int y, const RECT& region) { - // Full-height toggle button. - const RECT ftb = fullHtBtnRect(region); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { - bankPanelToggledBanksFullHeight(); - return true; - } - // "+" create button. - const RECT cb = createBtnRect(region); - if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) { - doCreateBank(); - return true; - } - // Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate). - const TabStripRect strip = banksTabStripRect(region); - const std::vector tabs = namedBanks(); - const int n = static_cast(tabs.size()); - const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll); - if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) { - const TabStripLayout layout = - computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); - const int step = kTabSpec.tabWidth; - const int desired = g_panel.tabScroll + - (hit.kind == TabHitKind::ScrollLeft ? -step : step); - g_panel.tabScroll = clampTabScroll(desired, layout); - invalidatePanel(); - return true; - } - if (hit.kind == TabHitKind::Tab) { - const Bank* bk = tabs[static_cast(hit.index)]; - if (bk->id != g_panel.shownBankId) { - g_panel.shownBankId = bk->id; // browse: show this bank's grid - g_panel.selection = Selection{}; // grid changed — reset selection - stopAudition(); - } - g_panel.focusedRegion = Region::Banks; - invalidatePanel(); - return true; - } - return false; -} - -// Handles the pool region's full-height toggle. Returns true if consumed. -bool handlePoolChromeClick(int x, int y, const RECT& region) { - const RECT ftb = fullHtBtnRect(region); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { - bankPanelToggledPoolFullHeight(); - return true; - } - return false; -} - -// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by -// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box. -int footerToggleSegmentHit(int x, int y, int w, int h) { - if (!g_panel.session) return -1; - const FooterBarLayout fb = footerBarLayoutFor(w, h); - if (fb.toggle.empty()) return -1; - const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; - return hitTestSegment(x, y, th, modeCount()); -} - -// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / -// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click -// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. -void handleClick(int x, int y) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right - // strip, outside the action rect), then the frequent capture/placement buttons. A button - // fires its registered action via the command-id contract; the band is claimed whole (a - // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts. - { - const MenuButtonRect mb = topMenuButtonRect(w); - if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } - } - if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return; - // Claim the WHOLE top band (including the reserved right strip between the last button and - // the More button) so a click there is inert chrome, never a fall-through to the grid. - if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return; - - // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design] - // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a - // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar / - // grid so a footer click never selects a cell. - { - const int seg = footerToggleSegmentHit(x, y, w, h); - if (seg >= 0) { - const std::vector& modes = g_panel.session->view().modes().all(); - if (seg < static_cast(modes.size())) { - applyMode(g_panel.session->view(), - modes[static_cast(seg)].id, nullptr); - invalidatePanel(); - } - return; - } - - const FooterBarLayout fb = footerBarLayoutFor(w, h); - if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { - // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates - // the SESSION's tail setting (capture reads it; persist saves it with the project) - // and marks the project dirty — touches NOTHING in the bank/arrange. - TailSetting& tail = g_panel.session->tail(); - tail.mode = cycleTailMode(tail.mode); - markTailDirty(); - invalidatePanel(); - return; - } - - // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered - // command id (fork R-E: dispatch the command, not the session directly) so the panel - // affordance and the bindable action share the one guarded dry-run/confirm/delete path - // in doBankPruneFolder. A 0 id (pre-registration) no-ops. - const ButtonRect pb = pruneButtonRectFor(w, h); - if (hitTestPruneButton(x, y, pb)) { - const int cmd = bankPruneCommandId(); - if (cmd != 0) Main_OnCommand(cmd, 0); - return; - } - } - - // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the - // command-id contract. Claimed whole like the top toolbar. - if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; - - // Region chrome (headers, tab strip, buttons). - if (poolShown()) { - const RECT pr = poolRegionRect(w, h); - if (y >= pr.top && y < regionGridRect(pr, false).top) { - if (handlePoolChromeClick(x, y, pr)) return; - } - } - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - if (y >= br.top && y < regionGridRect(br, true).top) { - if (handleBanksChromeClick(x, y, br)) return; - } - } - - // Grid selection. Resolve which region's grid the point is in. - Region reg = Region::Pool; - if (!regionAt(x, y, reg)) return; - const bool isBanks = reg == Region::Banks; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An - // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a - // click on a gap clears selection, exactly like a click in the margin) — empty slots - // are decorative, not selectable. - const RegionDisplay disp = regionDisplay(region, isBanks, reg); - const int hitSlot = hitTestSlot(x, y, disp.slotRects); - const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); - const int count = disp.occupiedCount(); - - // Switching focus region reseeds the selection there. - if (g_panel.focusedRegion != reg) { - g_panel.focusedRegion = reg; - g_panel.selection = Selection{}; - stopAudition(); - } - - if (hit < 0) { - if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { - g_panel.selection = Selection{}; - stopAudition(); - } - invalidatePanel(); - return; - } - - // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell: - // - // • Already-selected cell: defer the selection change to LBUTTONUP so a plain - // press on a multi-selection doesn't collapse it before we know whether a drag - // will happen. Arm the drag with the current (multi-)selection as the payload - // candidate; only the caret moves immediately. - // - // • Unselected cell: apply the plain-click selection immediately (collapses to - // the single pressed cell) THEN arm a drag from it — so the user can press-and- - // drag in one gesture without a prior selecting click. The selection is set - // before arming so that focusedSelectionIds() resolves the right payload when - // the threshold is crossed in onMouseMove. - // - // ctrl / shift presses are selection-only gestures — no drag arm in either case. - const bool onSelected = g_panel.selection.contains(hit); - if (!ctrlDown() && !shiftDown()) { - if (!onSelected) { - // Commit the single-cell selection now so the drag payload is correct. - g_panel.selection = applyClick(g_panel.selection, hit, false, false, count); - g_panel.selItemCount = count; - } else { - // Move the caret to the pressed cell; defer collapsing multi-selection. - g_panel.selection.focus = hit; - } - g_panel.dragArmed = true; - g_panel.dragStartX = x; - g_panel.dragStartY = y; - g_panel.dragSourceRegion = reg; - // Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the - // panel client rect before the drag threshold is crossed. Without capture, outside moves - // are not delivered, so a fast straight-out drag never transitions dragArmed → dragging - // and the OS drag-out never fires on the first pass. The capture is released on button-up - // (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch) - // and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState). - SetCapture(g_panel.hwnd); - invalidatePanel(); - return; - } - - g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count); - g_panel.selItemCount = count; - invalidatePanel(); -} - -// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. -// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is -// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, -// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns -// false so the caller can let REAPER/the docker handle the wheel normally). On a real -// change it mutates the SESSION's tail setting, marks the project dirty (so it saves), -// and repaints the live length. Returns true iff the wheel was consumed. -bool handleWheel(int x, int y, int delta) { - if (!g_panel.session) return false; - if (!pointInFooter(x, y)) return false; - - TailSetting& tail = g_panel.session->tail(); - if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only - - // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad - // that sends fractional deltas still steps predictably. Sign carries direction. - const int notches = delta / 120; - if (notches == 0) return false; // sub-notch movement — nothing to apply yet - - const double before = tail.manualMs; - tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); - if (tail.manualMs == before) return true; // already at a bound — consumed, no change - - markTailDirty(); - invalidatePanel(); // label shows the new length live - return true; -} - -// The column count for a region's current grid width (nav needs the layout's wrap). -int columnsForRegion(Region reg) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const RECT region = reg == Region::Banks ? banksRegionRect(w, h) - : poolRegionRect(w, h); - const RECT grid = regionGridRect(region, reg == Region::Banks); - return columnsForWidth(grid.right - grid.left, kGrid); -} - -bool isOurWindow(HWND hwnd) { - for (HWND w = hwnd; w; w = GetParent(w)) - if (w == g_panel.hwnd) return true; - return false; -} - -bool handleKey(int vk) { - const int count = focusedItemCount(); - if (count <= 0) return false; - - switch (vk) { - case VK_LEFT: - case VK_RIGHT: - case VK_UP: - case VK_DOWN: { - const NavKey nk = vk == VK_LEFT ? NavKey::Left - : vk == VK_RIGHT ? NavKey::Right - : vk == VK_UP ? NavKey::Up - : NavKey::Down; - g_panel.selection = navigate(g_panel.selection, nk, - columnsForRegion(g_panel.focusedRegion), - count, shiftDown()); - g_panel.selItemCount = count; - invalidatePanel(); - return true; - } - case VK_RETURN: - case VK_SPACE: - if (g_panel.selection.focus >= 0) - startAudition(g_panel.selection.focus); - return true; - case VK_ESCAPE: - stopAudition(); - return true; - case VK_DELETE: { - // Remove the focused-region selection (B5). Silent; a no-op when nothing - // is selected. - const std::vector sel = focusedSelectionIds(); - if (sel.empty()) return false; // nothing selected — let the key fall through - removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); - return true; - } - default: - return false; - } -} - -int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { - if (!msg || msg->message != WM_KEYDOWN) return 0; - if (!g_panel.open || !g_panel.hwnd) return 0; - if (!isOurWindow(GetFocus())) return 0; - return handleKey(static_cast(msg->wParam)) ? 1 : 0; -} - -accelerator_register_t g_accel{translateAccel, true, nullptr}; -bool g_accelRegistered = false; - -void registerAccel() { - if (g_accelRegistered || !g_rec) return; - g_rec->Register("accelerator", &g_accel); - g_accelRegistered = true; -} - -void unregisterAccel() { - if (!g_accelRegistered || !g_rec) return; - g_rec->Register("-accelerator", &g_accel); - g_accelRegistered = false; -} - -// --- Drag (move between regions/onto a tab) ----------------------------------- - -constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag - -// Resolves the drop target under client (x, y) during a drag, updating dropKind / -// dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; -// anywhere else -> none. -void updateDropTarget(int x, int y) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - const TabStripRect strip = banksTabStripRect(br); - const std::vector tabs = namedBanks(); - const TabHit hit = hitTestTabStrip(x, y, strip, - static_cast(tabs.size()), kTabSpec, - g_panel.tabScroll); - if (hit.kind == TabHitKind::Tab) { - g_panel.dropKind = DropKind::Tab; - g_panel.dropBankId = tabs[static_cast(hit.index)]->id; - return; - } - // Tab takes precedence over the region; if the point is in the banks region but - // not on a specific tab, treat the whole grid as a drop zone for the shown bank. - // No valid target when there are no named banks or no shown bank. - if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { - if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { - g_panel.dropKind = DropKind::BanksRegion; - g_panel.dropBankId = g_panel.shownBankId; - return; - } - } - } - if (poolShown()) { - const RECT pr = poolRegionRect(w, h); - const RECT grid = regionGridRect(pr, false); - if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { - g_panel.dropKind = DropKind::PoolRegion; - return; - } - } -} - -// The destination bank id under the current drop target (pool id for PoolRegion; the tab/ -// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's -// dropKind/dropBankId — the single source of "what bank is under the pointer". -std::string dropTargetBankId() { - switch (g_panel.dropKind) { - case DropKind::PoolRegion: return std::string(kPoolBankId); - case DropKind::Tab: - case DropKind::BanksRegion: return g_panel.dropBankId; - case DropKind::None: return {}; - } - return {}; -} - -// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) -// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget -// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the -// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); -// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the -// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path -// in onMouseMove BEFORE this runs, so here the pointer is always inside the client. -void classifyCardDrag(int x, int y) { - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const PanelClientRect client{cr.left, cr.top, w, h}; - - const std::string destBank = dropTargetBankId(); - DragModifiers mods; - mods.ctrl = ctrlDown(); - mods.alt = altDown(); - - if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { - // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over - // in the SOURCE bank's own region display + whether it is occupied. - // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond - // the last occupied card resolves to a valid trailing slot, not a -1 miss. - mods.region = DropRegion::SameBankGrid; - const bool isBanks = g_panel.dragSourceRegion == Region::Banks; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); - const RECT grid = regionGridRect(region, isBanks); - const int gridW = grid.right - grid.left; - const std::vector dropRects = - computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1, - gridW, kGrid); - // Translate the drop rects to client space (matching regionDisplay's translation). - std::vector dropRectsClient = dropRects; - for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; } - const int slot = hitTestSlot(x, y, dropRectsClient); - mods.targetSlot = slot; - mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty(); - g_panel.dragTargetSlot = slot; - } else if (!destBank.empty()) { - mods.region = DropRegion::OtherBankOrTab; // move/copy to a different bank/tab - } else { - mods.region = DropRegion::DeadSpace; // header/footer/gap — a no-op drop - } - - const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; - g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); -} - -// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure -// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. -// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* -// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> -// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor -// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would -// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap -// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); -// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is -// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. -void applyDragCursor(CardGesture g) { - const char* idc = IDC_ARROW; - switch (cursorForGesture(g)) { - case CursorCue::Reorder: idc = IDC_SIZEALL; break; - case CursorCue::Move: idc = IDC_HAND; break; - case CursorCue::Copy: idc = IDC_UPARROW; break; - case CursorCue::Replace: idc = IDC_SIZEWE; break; - case CursorCue::OsDragOut: return; // OS drag owns the cursor; do not fight it - case CursorCue::Default: idc = IDC_ARROW; break; - } - SetCursor(LoadCursor(nullptr, idc)); -} - -// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring -// handleClick's precedence exactly (so the element that lights on hover is the one a click -// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client -// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure -// resolution over the same pure geometry the click path uses. -Hover resolveHover(int x, int y) { - if (!g_panel.hwnd) return Hover{}; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - // TOP toolbar: the far-right More button, then the frequent buttons (matching the click - // order — first zone top-to-bottom). - { - const MenuButtonRect mb = topMenuButtonRect(w); - if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; - const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); - if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; - } - // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). - { - const int seg = footerToggleSegmentHit(x, y, w, h); - if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; - const FooterBarLayout fb = footerBarLayoutFor(w, h); - if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1}; - const ButtonRect pb = pruneButtonRectFor(w, h); - if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; - } - // BOTTOM toolbar buttons. - { - const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); - if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; - } - // Region chrome: full-height toggles, create button, tabs. - if (poolShown()) { - const RECT pr = poolRegionRect(w, h); - const RECT ftb = fullHtBtnRect(pr); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) - return Hover{HoverKind::FullHtPool, -1}; - } - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - const RECT ftb = fullHtBtnRect(br); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) - return Hover{HoverKind::FullHtBanks, -1}; - const RECT cb = createBtnRect(br); - if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) - return Hover{HoverKind::CreateBank, -1}; - const TabStripRect strip = banksTabStripRect(br); - const std::vector tabs = namedBanks(); - const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), - kTabSpec, g_panel.tabScroll); - if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; - } - return Hover{}; -} - -// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no -// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also -// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only -// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the -// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. -void updateHover(int x, int y) { - const Hover next = resolveHover(x, y); - if (next != g_panel.hovered) { - g_panel.hovered = next; - g_panel.hoverSinceTick = GetTickCount(); - if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } - invalidatePanel(); - } -} - -// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past -// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the -// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; -// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the -// current hover has no tooltip (grid / chrome / the More button). -void maybeShowTooltip() { - if (g_panel.tooltipShown) return; - const HoverKind k = g_panel.hovered.kind; - if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; - const unsigned int now = GetTickCount(); - if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { - g_panel.tooltipShown = true; - invalidatePanel(); - } -} - -void onMouseMove(int x, int y) { - // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns - // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when - // the pointer is over the grid / dead space. - if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); - - if (g_panel.dragArmed && !g_panel.dragging) { - if (std::abs(x - g_panel.dragStartX) > kDragThreshold || - std::abs(y - g_panel.dragStartY) > kDragThreshold) { - // Threshold crossed — begin the drag. Snapshot the payload NOW. - g_panel.dragging = true; - g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); - g_panel.dragSampleIds = focusedSelectionIds(); - // The single card actually grabbed = the focus ordinal's id. This is the L7 - // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card - // gesture, distinct from the multi-select move/copy payload in dragSampleIds. - { - const RegionDisplay disp = focusedDisplay(); - const int f = g_panel.selection.focus; - g_panel.dragPrimaryId = - (f >= 0 && f < disp.occupiedCount()) - ? disp.orderedIds[static_cast(f)] : std::string{}; - } - g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now - g_panel.tooltipShown = false; // a drag never shows a tooltip - // SetCapture was already called at drag-arm time (handleClick); no re-capture needed. - } - } - if (g_panel.dragging) { - // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the - // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant - // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture - // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an - // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a - // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays - // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; - const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); - - DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; - st.singleCapture = (g_panel.dragSampleIds.size() == 1); - - // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can - // only arise there) and only for a single-capture payload — the SDK hit-test is skipped - // on the common internal-drag path so it costs nothing there. The screen conversion is - // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. - FxDropTarget fx; - if (!inside && st.singleCapture) { - POINT sp{x, y}; - ClientToScreen(g_panel.hwnd, &sp); - fx = resolveFxDropTarget(sp.x, sp.y); - st.overReaperUi = fx.overReaperUi; - } - - const DragGesture gesture = decideGesture(x, y, client, st); - - if (gesture == DragGesture::InstrumentDrop) { - // Track the FX hotspot for the release; the highlight is REAPER's own FX-button - // hover feedback under the pointer (the drop is driven on button-up). We keep the - // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does - // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the - // panel does not also paint a bank-drop cue while the drag is out over a track. - g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - invalidatePanel(); - return; - } - - // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. - g_panel.instrumentDropTrack = nullptr; - - if (gesture == DragGesture::OsDrag) { - // Resolve the payload to existing on-disk paths BEFORE tearing down internal - // drag state (the resolver reads dragSourceBankId / dragSampleIds). - const std::vector paths = resolveDragPathsForOs(); - - // Reset internal drag state and release capture NOW: DoDragDrop runs its own - // modal loop and takes over mouse capture, so the internal drag must be fully - // wound down first (no stale dragging/dropKind, no lingering SetCapture). A - // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state - // (invariant #2 — nothing mutated). - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - g_panel.dragArmed = false; - g_panel.dragging = false; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); - invalidatePanel(); - - // Empty path list -> nothing draggable (all stale/missing); do not start a drag. - if (!paths.empty()) - initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows - return; - } - // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing - // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ - // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. - updateDropTarget(x, y); - classifyCardDrag(x, y); - applyDragCursor(g_panel.cardGesture); - invalidatePanel(); - } -} - -// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- -// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure -// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the -// batched undo point + saves). A no-op reorder (already at the target, model returns false) -// opens no undo point. Selection reasons over slot order, so it is cleared after — the -// fingerprint pass rebuilds it against the new order. -void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { - if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; - if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point - persistBankOp("ReaSampler: reorder sample"); - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is -// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure -// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, -// no undo point (per spec). One drop = one Ctrl-Z on success. -void doReplaceDrop(const std::string& newId, const std::string& oldId, - const std::string& bankId) { - if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; - if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP - persistBankOp("ReaSampler: replace sample"); - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// Clears all drag-state fields to their resting values. Called from every exit path -// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields -// stays consistent across all four sites. -void resetDragState() { - g_panel.dragArmed = false; - g_panel.dragging = false; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); - g_panel.instrumentDropTrack = nullptr; -} - -// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: -// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. -// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). -// * None -> a drop over dead space / the source-bank gap = no-op. -// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. -void onLBtnUp(int x, int y) { - if (g_panel.dragging) { - // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a - // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, - // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank - // drop (the pointer is out over a track, not over a bank region). Single-capture only (the - // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. - if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { - const std::string sampleId = g_panel.dragSampleIds.front(); - performInstrumentDrop(g_panel.instrumentDropTrack, - buildInstrumentDropPreset(sampleId)); - // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + - // its state (both undoable in performInstrumentDrop). No book change, no ext-state, - // no dirty-mark here. - } else { - updateDropTarget(x, y); - classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) - const CardGesture g = g_panel.cardGesture; - - if (g == CardGesture::Reorder) { - doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, - g_panel.dragTargetSlot); - } else if (g == CardGesture::Replace) { - // Replace targets the OCCUPANT of the target slot with the single grabbed card. - const bool isBanks = g_panel.dragSourceRegion == Region::Banks; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); - const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); - // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. - if (!occupant.empty() && occupant != g_panel.dragPrimaryId) - doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); - } else if (g == CardGesture::Move || g == CardGesture::Copy) { - const std::string destId = dropTargetBankId(); - if (!destId.empty() && destId != g_panel.dragSourceBankId && - !g_panel.dragSampleIds.empty()) { - transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, - /*copy=*/g == CardGesture::Copy); - } - } - } - // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). - SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - } else if (g_panel.dragArmed) { - // Press-release on a selected cell with no drag: treat as a plain click that - // collapses the multi-selection to the pressed cell (standard behavior). - // Release capture acquired at arm time (handleClick) — drag never started. - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - const BankModel* idx = indexForRegion(g_panel.focusedRegion); - const int count = idx ? static_cast(idx->size()) : 0; - const int focus = g_panel.selection.focus; - if (focus >= 0) - g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); - } - resetDragState(); - invalidatePanel(); -} - -// A right-click: on a named tab -> the tab management menu; on a grid cell of the -// focused region with a selection -> the move/copy menu. -void handleRightClick(int x, int y) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - // Tab management menu. - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - const TabStripRect strip = banksTabStripRect(br); - const std::vector tabs = namedBanks(); - const TabHit hit = hitTestTabStrip(x, y, strip, - static_cast(tabs.size()), kTabSpec, - g_panel.tabScroll); - if (hit.kind == TabHitKind::Tab) { - POINT pt{x, y}; - ClientToScreen(g_panel.hwnd, &pt); - showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); - return; - } - } - - // Grid selection menu (move/copy). Only when the right-click lands in the focused - // region's grid and there is a selection. - Region reg = Region::Pool; - if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && - !g_panel.selection.empty()) { - POINT pt{x, y}; - ClientToScreen(g_panel.hwnd, &pt); - showSelectionMenu(pt.x, pt.y); - } -} - -// --- Dialog proc + docking ---------------------------------------------------- - -// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands -// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active -// bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP -// (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) -// returns the file count; then each path is queried by index. Both Win32 and SWELL expose -// DragQueryFile/DragFinish with this contract. -void handleDropFiles(HDROP hDrop) { - std::vector paths; - const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); - paths.reserve(count); - for (UINT i = 0; i < count; ++i) { - // Query the required length first (excludes the NUL), then read into a sized buffer. - const UINT len = DragQueryFile(hDrop, i, nullptr, 0); - if (len == 0) continue; - std::vector buf(static_cast(len) + 1, '\0'); - DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); - std::string p(buf.data()); - if (!p.empty()) paths.push_back(std::move(p)); - } - DragFinish(hDrop); - if (!paths.empty()) ingestDroppedFiles(paths); -} - -WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { - switch (msg) { - case WM_DROPFILES: - // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import - // into the active bank (bank-fill only). wParam is the HDROP. - handleDropFiles(reinterpret_cast(wParam)); - return 0; - case WM_PAINT: { - PAINTSTRUCT ps; - HDC hdc = BeginPaint(hwnd, &ps); - paintPanel(hwnd, hdc); - EndPaint(hwnd, &ps); - return 0; - } - case WM_LBUTTONDOWN: { - SetFocus(hwnd); - handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - } - case WM_MOUSEMOVE: - onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_LBUTTONUP: - onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_RBUTTONDOWN: - SetFocus(hwnd); - handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_CAPTURECHANGED: - // Capture lost (pointer left window pre-threshold and released outside, or another - // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale - // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is - // mutated on a cancel; the cursor is restored to the arrow. - if (g_panel.dragArmed || g_panel.dragging) { - resetDragState(); - SetCursor(LoadCursor(nullptr, IDC_ARROW)); - invalidatePanel(); - } - return 0; - case WM_MOUSEWHEEL: { - // Fine-adjust the Manual tail length when the wheel is over the footer. - // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in - // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so - // convert to client space before hit-testing the footer. The signed wheel - // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, - // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer - // handler acts, so scrolling elsewhere in the dock still behaves normally. - POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; - ScreenToClient(hwnd, &pt); - const int delta = static_cast(HIWORD(wParam)); - return handleWheel(pt.x, pt.y, delta) ? 1 : 0; - } - case WM_DESTROY: - if (GetCapture() == hwnd) ReleaseCapture(); - stopAudition(); - g_panel.selection = Selection{}; - resetDragState(); - g_panel.hovered = Hover{}; - g_panel.tooltipShown = false; - g_panel.hwnd = nullptr; - g_panel.open = false; - return 0; - default: - break; - } - return 0; -} - -void openPanel() { - if (g_panel.open && g_panel.hwnd) { - DockWindowActivate(g_panel.hwnd); - return; - } - initPreview(); - - // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so - // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts - // are torn down once at bankPanelShutdown. All panel text draws through these. - kitFontsInit(); - - g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), - GetMainHwnd(), dlgProc, 0); - if (!g_panel.hwnd) return; - - // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position - // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler - // Bank beta") and does not fight over stable's saved dock slot (the identstr is a - // REAPER-global collision surface — it keys the persisted dock position). - DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); - DockWindowActivate(g_panel.hwnd); - g_panel.open = true; - - // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). - // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, - // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 - // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration - // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses - // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means - // would still ingest — only the accept opt-in is gated. -#ifdef _WIN32 - DragAcceptFiles(g_panel.hwnd, TRUE); -#endif - - registerAccel(); - - reconcileShownBank(); - refreshFingerprint(); -} - -void closePanel() { - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - stopAudition(); - g_panel.selection = Selection{}; - resetDragState(); - unregisterAccel(); - if (g_panel.hwnd) { - DockWindowRemove(g_panel.hwnd); - DestroyWindow(g_panel.hwnd); - g_panel.hwnd = nullptr; - } - g_panel.open = false; -} - -} // namespace - -// --- Public API --------------------------------------------------------------- - -void bankPanelInit(ReaSamplerSession* session) { - g_panel.session = session; -} - -// Returns true only when the panel window is actually visible to the user right now. -// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the -// HWND and g_panel.open are still live — the live query is the source of truth for -// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp). -static bool panelEffectivelyVisible() { - return g_panel.hwnd && IsWindowVisible(g_panel.hwnd); -} - -void bankPanelToggle() { - // Decide from live visibility, not the cached g_panel.open flag. - // Alt+D hides the docker without destroying the window, leaving g_panel.open - // stale (true) while the panel is gone. Using IsWindowVisible avoids the - // double-fire needed to re-show the panel after a docker hide. - if (panelEffectivelyVisible()) - closePanel(); - else - openPanel(); -} - -bool bankPanelIsOpen() { - // Derive from live window state so the Actions-list checkmark stays honest - // even after Alt+D hides the docker without notifying the extension. - return panelEffectivelyVisible(); -} - -std::vector bankPanelSelectedSampleIds() { - return focusedSelectionIds(); -} - -std::string bankPanelSelectedSourceBankId() { - // The focused region's displayed bank is the move/copy source. Default to the - // pool (a safe source) when nothing is selected / the panel never opened. - if (g_panel.selection.empty()) return std::string(kPoolBankId); - const std::string id = bankIdForRegion(g_panel.focusedRegion); - return id.empty() ? std::string(kPoolBankId) : id; -} - -void bankPanelNotifyProjectLoaded() { - // Persist restored a project's membership + active mode this tick (main.cpp calls - // this from the same consumeLoadSignal() branch that reapplies the active mode). - // Arm the new-content detector to re-baseline on its next tick so the just-loaded - // project's pre-existing content is treated as the baseline (nothing new) rather - // than diffed against the previous project and mass-tagged into the active mode. - // A flag (not an inline reset) because detectNewContent owns the baseline and runs - // later in the SAME OnTimer tick — it drains this and re-baselines against the live - // set in one place, keeping the reset and the observe() adjacent and ordered. - g_panel.reloadPending = true; -} - -void bankPanelRefresh() { - // New-content auto-tag detection runs EVERY tick regardless of panel open/close: - // tracks/items are created in the arrange view, not the panel, so detection must - // not be gated on the dock being visible. READ-ONLY on the project; only mutates - // the in-memory membership index (persist saves it like any action-driven tag). - const bool tagged = detectNewContent(); - - // Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a - // track can only newly become multi-mode when auto-tag placed content on it. Unlike - // the invisible membership tag above, minting is a visible structural mutation - // (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo - // block and only mints for tracks that hold >1 mode's content — a single-mode track - // is left to D1 whole-track parking. Managed lanes only; manual lanes untouched. - if (tagged && g_panel.session) { - ReaProject* proj = EnumProjects(-1, nullptr, 0); - mintManagedLanes(g_panel.session->view(), proj); - } - - if (!g_panel.open || !g_panel.hwnd) return; - - // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a - // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. - maybeShowTooltip(); - - if (refreshFingerprint()) - InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -TailSetting bankPanelTailSetting() { - // The authoritative setting lives in the session (session->tail()) so it travels - // inside the .rpp: it loads per project and saves with the project. This stays the - // read seam for the capture actions. manualMs is clamped here so a caller always - // receives a within-cap length regardless of what was stored/scrolled. - TailSetting s = currentTail(); - s.manualMs = clampManualMs(s.manualMs); - return s; -} - -BankPanelFullHeight bankPanelFullHeight() { - return g_panel.fullHeight; -} - -static void setFullHeight(BankPanelFullHeight target) { - g_panel.fullHeight = - (g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; - if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -void bankPanelToggledPoolFullHeight() { - setFullHeight(BankPanelFullHeight::PoolOnly); -} - -void bankPanelToggledBanksFullHeight() { - setFullHeight(BankPanelFullHeight::BanksOnly); -} - -void bankPanelInvalidate() { - if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -void bankPanelShutdown() { - closePanel(); - deinitPreview(); - kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) - g_panel.cache.clear(); - g_panel.session = nullptr; -} - -} // namespace reasampler diff --git a/src/bank_panel.h b/src/bank_panel.h deleted file mode 100644 index 1d5d52b..0000000 --- a/src/bank_panel.h +++ /dev/null @@ -1,131 +0,0 @@ -#pragma once -#include "core/namespaces.h" -// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns -// a SWELL dialog docked via DockWindowAddEx, and paints the current project's -// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts -// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing -// principle). Audition / multi-select / keyboard nav are Wave B. -// -// The header is REAPER-free as practical: main.cpp drives the panel through these -// free functions, passing the live session so the panel reads the current bank. -// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure -// layout math and cache keys live in bank_grid (unit-tested outside the DAW). - -#include -#include - -#include "core/capture/tail_control.h" // TailSetting — the panel's tail-mode toggle state - -namespace reasampler { - -class ReaSamplerSession; - -// Wires the panel into main.cpp's lifecycle. Called once after the API pointers -// are loaded, BEFORE the toggle action is registered. `session` must outlive the -// panel (it is the extension-lifetime g_session). Stores the session pointer the -// panel reads on every repaint; does not create the window yet. -void bankPanelInit(ReaSamplerSession* session); - -// Toggles the docked window: creates+docks it if hidden, hides+undocks it if -// shown. Bound to the "toggle bank panel" action. Safe to call before the first -// timer tick. -void bankPanelToggle(); - -// Whether the panel window is currently open/visible. Feeds the action's -// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. -bool bankPanelIsOpen(); - -// The stable ids of the currently-selected samples, in bank (insertion) order. -// Empty when nothing is selected or the panel has never opened. This is the clean -// seam the `insert` action reads to know WHAT to place — it returns ids (not grid -// indices) so the caller resolves against the live bank and is unaffected by the -// panel's internal index bookkeeping. READ of panel state only; no mutation. -// -// Note: the panel's selection is cleared on a bank change (capture / project -// load), so a returned id always names a sample present in the current bank at -// the moment of the call; the caller still tolerates an absent id gracefully. -// -// Phase B4 (vertical split): the selection lives in whichever REGION the user last -// interacted with (the pool grid on top or a named-bank grid below), which is NOT -// necessarily the active/capture-target bank. The returned ids therefore name -// samples in the FOCUSED region's displayed bank — the bank the user visibly -// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those -// ids belong to (the move/copy source). -std::vector bankPanelSelectedSampleIds(); - -// The bank id the current selection belongs to — the displayed bank of the region -// the user last interacted with (pool region -> the pool id; named-banks region -> -// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current -// selection, and it is distinct from the active/capture-target bank (active ≠ shown). -// Returns the pool id when nothing is selected or the panel has never opened (a safe -// default source). READ of panel state only; no mutation. -std::string bankPanelSelectedSourceBankId(); - -// Requests a repaint if the bank changed since the last paint (generation bump). -// Cheap when nothing changed. Driven by the timer so a capture / project load is -// reflected without the panel diffing the bank itself. -void bankPanelRefresh(); - -// Notifies the panel that persist just (re)loaded a project's view model (membership + -// active mode). main.cpp calls this on the exact tick it drains persist's load signal -// and reapplies the active mode. It re-arms the new-content detector so the just-loaded -// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), -// never diffed against the previously-open project and mass-tagged into the active mode. -// This coordinates the detector's project-identity signal with persist's authoritative -// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, -// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ -// arm of panel state only; no project or bank mutation. -void bankPanelNotifyProjectLoaded(); - -// The panel's current tail-mode setting (mode + Manual length), read by the plain -// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture -// applies whatever the panel toggle is set to. Default None (exact bounds) — a -// capture with no explicit choice stays byte-identical to today. Extension-session -// setting: persists across project loads and panel open/close within a REAPER session; -// resets to None only when the extension unloads (fresh REAPER session). Project -// persistence across REAPER restarts is a noted follow-on. -// Safe to call before the panel has ever opened (returns the default). READ of panel -// state only; the toggle is mutated by a click inside the panel, never here. -TailSetting bankPanelTailSetting(); - -// The vertical-split full-height layout state (Phase B). The bank window splits -// vertically — pool on top, named-banks region below — and two toggles collapse the -// split: pool full-height (hide the named-banks region) and banks full-height (hide -// the pool). The two are mutually exclusive with the default (both regions shown), -// so one enum captures the whole state. -// -// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives -// here beside the tail setting — the other session-level view-layout bit the panel -// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not -// project state, so it must not travel with the .rpp. In-memory for the extension's -// lifetime; resets to Split on unload. -enum class BankPanelFullHeight { - Split, // default: pool region on top, named-banks region below - PoolOnly, // pool full-height — named-banks region hidden - BanksOnly, // banks full-height — pool region hidden -}; - -// The current full-height layout state (default Split). READ by B4's panel to decide -// which region(s) to draw. Safe before the panel has ever opened. -BankPanelFullHeight bankPanelFullHeight(); - -// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from -// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool -// full-height" action. Requests a repaint so an open panel reflects the change. -void bankPanelToggledPoolFullHeight(); - -// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. -// Bound to the "banks full-height" action. Requests a repaint. -void bankPanelToggledBanksFullHeight(); - -// Requests an immediate repaint of the panel if it is open. A no-op when the panel -// is closed (safe to call unconditionally). Called by the actions layer after a -// mode change so the footer [Arrange|Design] toggle reflects the new mode without -// requiring a hide/reshow. -void bankPanelInvalidate(); - -// Tears the panel down on extension unload: destroys the window and releases any -// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. -void bankPanelShutdown(); - -} // namespace reasampler diff --git a/src/ingest.cpp b/src/ingest.cpp index 250b201..6496abc 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -21,7 +21,7 @@ #include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode #include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex #include "core/model/bank_model.h" // Sample, AddResult, findByHash -#include "bank_panel.h" // bankPanelRefresh +#include "shell/panel/panel_input.h" // bankPanelRefresh #include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) diff --git a/src/shell/capture/insert.cpp b/src/shell/capture/insert.cpp index d6344dc..15e5dde 100644 --- a/src/shell/capture/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -38,7 +38,7 @@ #include #include "core/model/bank_model.h" -#include "bank_panel.h" +#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId #include "core/capture/capture_paths.h" #include "persist.h" diff --git a/src/shell/panel/panel_audition.cpp b/src/shell/panel/panel_audition.cpp new file mode 100644 index 0000000..d05136f --- /dev/null +++ b/src/shell/panel/panel_audition.cpp @@ -0,0 +1,117 @@ +// panel_audition.cpp — the audition/preview engine seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; M5 Wave B). HOT PATH GUARDRAIL (T4-28 / Q-W2): the +// preview path stays a DIRECT free-function call-through — no interface, no virtual +// dispatch, no added header->TU indirection; the idle path is unchanged in shape. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include + +#include "shell/panel/panel_state.h" + +// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): +// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the +// STOCK symbols (not SWS-only) — see the audition section below. +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_PlayPreview +#define REAPERAPI_WANT_StopPreview +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +// --- Audition preview --------------------------------------------------------- +// +// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW +// playback only. It NEVER inserts into the arrange, creates items/tracks, or +// mutates the project or bank. PlayPreview streams a caller-owned PCM_source +// through REAPER's preview bus and touches nothing in the project. +// +// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by +// signature/struct, not semantics — DAW-verify): +// 1. REAPER's audio thread reads the preview_register_t by POINTER while the +// preview is active (the struct's own comment mandates a cs/mutex we init), +// so the register must outlive playback — we hold it in g_panel (static), +// never on the stack. +// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it +// returns, making it safe to PCM_Source_Destroy the source immediately after. +// This is the conventional contract (SWS' preview helpers rely on it) but is +// NOT documented in the header — flagged. If a rare race surfaced, the fix is +// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). +// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not +// set mono (&1024). volume 1.0, loop false, curpos 0. + +void initPreview() { + if (g_panel.previewInited) return; +#ifdef _WIN32 + InitializeCriticalSection(&g_panel.preview.cs); +#else + pthread_mutex_init(&g_panel.preview.mutex, nullptr); +#endif + g_panel.previewInited = true; +} + +void stopAudition() { + if (g_panel.previewActive) { + StopPreview(&g_panel.preview); + g_panel.previewActive = false; + } + if (g_panel.previewSrc) { + PCM_Source_Destroy(g_panel.previewSrc); + g_panel.previewSrc = nullptr; + } + g_panel.preview.src = nullptr; +} + +void deinitPreview() { + if (!g_panel.previewInited) return; +#ifdef _WIN32 + DeleteCriticalSection(&g_panel.preview.cs); +#else + pthread_mutex_destroy(&g_panel.preview.mutex); +#endif + g_panel.previewInited = false; +} + +// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. +// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw +// BankModel position. +void startAudition(int idx) { + stopAudition(); + + const BankModel* index = indexForRegion(g_panel.focusedRegion); + if (!index) return; + const RegionDisplay disp = focusedDisplay(); + if (idx < 0 || idx >= disp.occupiedCount()) return; + const Sample* s = index->query(disp.orderedIds[static_cast(idx)]); + if (!s) return; + + const std::string projectDir = currentProjectDir(); + const std::string abs = resolveBankFile(projectDir, s->relativePath); + if (abs.empty()) return; + + PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); + if (!src) return; + + g_panel.preview.src = src; + g_panel.preview.m_out_chan = 0; + g_panel.preview.curpos = 0.0; + g_panel.preview.loop = false; + g_panel.preview.volume = 1.0; + g_panel.preview.peakvol[0] = 0.0; + g_panel.preview.peakvol[1] = 0.0; + g_panel.preview.preview_track = nullptr; + + if (PlayPreview(&g_panel.preview) != 0) { + g_panel.previewSrc = src; + g_panel.previewActive = true; + } else { + PCM_Source_Destroy(src); + g_panel.preview.src = nullptr; + } +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp new file mode 100644 index 0000000..b3f630d --- /dev/null +++ b/src/shell/panel/panel_bank_ops.cpp @@ -0,0 +1,478 @@ +// panel_bank_ops.cpp — the bank-CRUD + menus seam of the docked bank panel (Q-W2 +// split of bank_panel.cpp; Phase B4/B5). The SINGLE home of the panel-side bank verbs +// (create / rename / delete / evacuate / activate / move / copy / remove) — the owner +// Q-W4 dedupes actions.cpp against — plus the book/bank accessors, the popup menus +// that drive them, and the selection-id / OS-drag path resolvers. +// +// Each op mutates g_session.book() then persists via persistBankOp() (one bank op = +// one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank BOOK — +// that is the whole point of B4 — but only the index/model + ext-state, never the +// arrange, never a sample file on disk (bank ops are index-only; files stay put — +// CONTEXT.md §Multi-bank). REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL +// mutation any Bank*/BankModel& is invalid — resolve fresh, pass ids. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_bank_ops.h" + +#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) +#include "persist.h" // ReaSamplerSession — the live session the ops mutate + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetUserInputs +#define REAPERAPI_WANT_ShowMessageBox +#define REAPERAPI_WANT_Main_OnCommand +#define REAPERAPI_WANT_genGuid +#define REAPERAPI_WANT_guidToString +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +namespace fs = std::filesystem; + +// --- Current-project directory (mirrors persist.cpp's derivation) ------------- +std::string currentProjectDir() { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + std::string rpp(buf.data()); + if (rpp.empty()) return {}; + return normalizeSlashes(fs::path(rpp).parent_path().string()); +} + +// --- Book / bank accessors ---------------------------------------------------- + +BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } + +// The BankModel a region currently displays. Pool region -> the pool; banks region -> +// the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved +// FRESH every call (never cached across a mutation). +const BankModel* indexForRegion(Region r) { + BankBook* b = book(); + if (!b) return nullptr; + if (r == Region::Pool) return &b->pool().index; + if (g_panel.shownBankId.empty()) return nullptr; + return b->index(g_panel.shownBankId); +} + +// The bank id a region displays (pool id, or the shown tab's id; "" when none). +std::string bankIdForRegion(Region r) { + if (r == Region::Pool) return std::string(kPoolBankId); + return g_panel.shownBankId; +} + +// The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. +std::vector namedBanks() { + std::vector out; + BankBook* b = book(); + if (!b) return out; + for (const Bank& bk : b->banks()) + if (!bk.isPool()) out.push_back(&bk); + return out; +} + +// --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- +// +// Each op mutates g_session.book() then persists via persistBankOp(). After a +// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we +// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an +// unsaved project the empty-close discard in persistBankOp ensures no stale state +// survives (matches the capture/B3 quiet-persist idiom). + +// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). +bool promptText(const char* title, const char* caption, const std::string& initial, + std::string& out) { + std::vector buf(512, '\0'); + std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); + const std::string captions = std::string(caption) + ",separator=\x1f"; + if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), + static_cast(buf.size()))) + return false; + std::string s(buf.data()); + if (s.empty()) return false; + out = std::move(s); + return true; +} + +// Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). +std::string mintBankId() { + GUID g{}; + genGuid(&g); + char buf[64] = {0}; + guidToString(&g, buf); + return std::string(buf); +} + +void doCreateBank() { + if (!book()) return; + std::string name; + if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; + const std::string id = mintBankId(); + if (!book()->createBank(id, name)) { + ShowMessageBox("A bank with that name already exists.", + "ReaSampler: create bank", 0); + return; + } + g_panel.shownBankId = id; // show the freshly-created bank + g_panel.focusedRegion = Region::Banks; + persistBankOp("ReaSampler: create bank"); + invalidatePanel(); +} + +void doRenameBank(const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + const std::string current = bk->displayName; // copy before any mutation + std::string newName; + if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; + if (!book()->renameBank(bankId, newName)) { + ShowMessageBox("Another bank already uses that name.", + "ReaSampler: rename bank", 0); + return; + } + persistBankOp("ReaSampler: rename bank"); + invalidatePanel(); +} + +// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the +// member count AND offers evacuate as the one-click alternative (Yes=delete anyway, +// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. +void doDeleteBank(const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + const std::size_t members = bk->index.size(); // read BEFORE any mutation + const std::string name = bk->displayName; + + if (members > 0) { + const std::string msg = + "\"" + name + "\" holds " + std::to_string(members) + + (members == 1 ? " sample" : " samples") + + ".\n\nYes -- delete the bank AND drop its samples (files are kept on disk " + "but no bank references them until prune).\nNo -- Evacuate them to the " + "pool first, then delete the empty bank (keeps the samples).\nCancel -- " + "do nothing."; + // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). + const int r = ShowMessageBox(msg.c_str(), + "ReaSampler: delete non-empty bank", 3); + if (r == 2) return; // Cancel + if (r == 7) { // No -> evacuate, then delete empty + if (!book()->evacuate(bankId)) return; + // book() may have reallocated; re-resolve nothing (we pass the id again). + } + // r == 6 (Yes) falls through to a plain delete (drops members). + } + if (!book()->deleteBank(bankId)) return; + // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- + // delete path moved/dropped members) — both change what a live instance could play. An + // empty-bank delete is purely organizational, no bump. + persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); + // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, + // nudge focus to the pool so the selection has a valid home. + if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; + invalidatePanel(); +} + +void doEvacuateBank(const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + if (!book()->evacuate(bankId)) return; + persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed + invalidatePanel(); +} + +void doActivateBank(const std::string& bankId) { + if (!book()) return; + if (!book()->setActiveBank(bankId)) return; // rejects an unknown id + persistBankOp("ReaSampler: activate bank"); + invalidatePanel(); +} + +// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass +// ids straight to the model op (no BankModel& cached across the loop's mutations). +// +// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): +// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally +// before the dest add collapses on hash), so the index DID mutate — counts. +// * COPY collapse: the source is left intact AND the dest already held the hash, +// so NOTHING changed — a true index no-op. Must NOT open an undo point. +// Hence: copy counts only real gains (Copied); move counts gains OR collapses. +void transferSamples(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy) { + if (!book()) return; + if (sampleIds.empty() || srcBankId == destBankId) return; + if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; + int ok = 0, collapsed = 0; + for (const std::string& sid : sampleIds) { + const TransferResult r = + copy ? book()->copySample(sid, srcBankId, destBankId) + : book()->moveSample(sid, srcBankId, destBankId); + switch (r) { + case TransferResult::Moved: + case TransferResult::Copied: ++ok; break; + case TransferResult::Collapsed: ++collapsed; break; + case TransferResult::RejectedUnknownBank: + case TransferResult::RejectedSampleAbsent: + case TransferResult::RejectedSameBank: break; + } + } + const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); + if (!mutated) return; // nothing changed — no persist, no undo point + + const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; + persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed + // The selection indexed into the source; after a move those indices are stale, so + // clear it (the fingerprint pass will also clear, but do it now for immediacy). + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to +// the file: a last-reference remove leaves the file on disk, orphaned until Phase R +// prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent +// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — +// one Ctrl-Z restores the index entry. Ids passed by value — no BankModel& cached +// across the loop's mutations. +void removeSamples(const std::vector& sampleIds, + const std::string& srcBankId) { + if (!book() || sampleIds.empty()) return; + if (!book()->bank(srcBankId)) return; + + int removed = 0; + for (const std::string& sid : sampleIds) + if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + if (removed == 0) return; // nothing changed — no persist, no undo point + + persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped + // The selection indexed into the source; after a remove those indices are stale, so + // clear it (the fingerprint pass will also clear, but do it now for immediacy). + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// The selection's sample ids resolved against the FOCUSED region's bank (source of a +// move/copy). Returns ids in bank order; empty when nothing selected. +std::vector focusedSelectionIds() { + // L7: selection ordinals index the DISPLAY (slot) order, not BankModel insertion order. + // orderedIds[i] is the id at selection ordinal i. + std::vector ids; + const RegionDisplay disp = focusedDisplay(); + const int count = disp.occupiedCount(); + for (int i : g_panel.selection.indices) + if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); + return ids; +} + +// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to +// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4 +// path machinery the panel uses for audition/insert (resolveBankFile over the current +// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each +// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region, +// which can differ once the pointer roams), resolved, stat'd, then handed to the pure +// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no +// mutation of sample / index / selection (invariant #2). +std::vector resolveDragPathsForOs() { + std::vector resolved; + BankBook* b = book(); + if (!b) return {}; + const BankModel* idx = b->index(g_panel.dragSourceBankId); + if (!idx) return {}; + + const std::string projectDir = currentProjectDir(); + resolved.reserve(g_panel.dragSampleIds.size()); + for (const std::string& sid : g_panel.dragSampleIds) { + const Sample* s = idx->query(sid); + if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve + ResolvedSample rs; + rs.absolutePath = resolveBankFile(projectDir, s->relativePath); + rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath)); + resolved.push_back(std::move(rs)); + } + return assemblePathList(resolved).paths; +} + +// --- Popup menus -------------------------------------------------------------- +// +// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu +// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of +// (label -> small int command), track it at screen coords, and switch on the return. +// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD +// hands the chosen id straight back, so no hookcommand routing is involved. + +// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both +// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative +// position appending. Win32 and SWELL both treat pos < 0 as an append. +void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { + UINT flags = MF_BYPOSITION | MF_STRING; + if (grayed) flags |= MF_GRAYED; + InsertMenu(menu, -1, flags, id, text); +} +void menuSeparator(HMENU menu) { + InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); +} + +// Menu command ids (local to a popup). +enum : unsigned int { + kMenuNone = 0, + kMenuActivate = 100, + kMenuRename, + kMenuDelete, + kMenuEvacuate, + kMenuCreate, + kMenuRemove, // remove selected sample(s) from the source bank (B5) + kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index + kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index +}; + +// Shows the right-click context menu for a named-bank TAB: activate / rename / delete +// / evacuate that bank, plus a create entry. Drives the id-keyed ops. +void showTabMenu(int screenX, int screenY, const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + const bool isActive = book()->activeBankId() == bankId; + const bool nonEmpty = !bk->index.empty(); + + HMENU menu = CreatePopupMenu(); + menuAppend(menu, kMenuActivate, + isActive ? "Active (capture target)" : "Activate (make capture target)", + /*grayed=*/isActive); + menuSeparator(menu); + menuAppend(menu, kMenuRename, "Rename..."); + menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); + menuAppend(menu, kMenuDelete, "Delete..."); + menuSeparator(menu); + menuAppend(menu, kMenuCreate, "New bank..."); + + const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, + g_panel.hwnd, nullptr); + DestroyMenu(menu); + + switch (cmd) { + case kMenuActivate: doActivateBank(bankId); break; + case kMenuRename: doRenameBank(bankId); break; + case kMenuEvacuate: doEvacuateBank(bankId); break; + case kMenuDelete: doDeleteBank(bankId); break; + case kMenuCreate: doCreateBank(); break; + default: break; + } +} + +// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the +// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup +// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we +// resolve + fire the corresponding registered command id via the SAME contract the visible +// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. +void showMoreMenu() { + if (!g_panel.hwnd) return; + const std::vector rows = overflowMenuRows(); + if (rows.empty()) return; + + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); + if (mb.empty()) return; + + HMENU menu = CreatePopupMenu(); + for (std::size_t i = 0; i < rows.size(); ++i) { + const int cmd = resolveBarCommandId(rows[i]); + // Grey a variant not registered on this channel (defensive — all three are registered). + menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), + /*grayed=*/cmd == 0); + } + + // Anchor the popup at the button's bottom-left, in screen coords. + POINT pt{mb.x, mb.y + mb.height}; + ClientToScreen(g_panel.hwnd, &pt); + const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); + DestroyMenu(menu); + + if (chosen >= 1 && chosen <= static_cast(rows.size())) { + const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); + if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); + } +} + +// Shows the move/copy menu for the current selection (the SOURCE is the focused +// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a +// copy submenu-free flat list (copy entries follow the move block). Move is the +// default (listed first); copy is the deliberate secondary act. +void showSelectionMenu(int screenX, int screenY) { + const std::vector sel = focusedSelectionIds(); + if (sel.empty()) return; + const std::string srcId = bankIdForRegion(g_panel.focusedRegion); + + // Destinations: pool + named banks, excluding the source. Ordinal order. + struct Dest { std::string id; std::string name; }; + std::vector dests; + if (srcId != std::string(kPoolBankId)) + dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); + for (const Bank* bk : namedBanks()) + if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); + + const std::string label = std::to_string(sel.size()) + + (sel.size() == 1 ? " sample" : " samples"); + + HMENU menu = CreatePopupMenu(); + // Move/copy blocks appear only when there is another bank to transfer to; Remove is + // always offered (it needs no destination — it drops the entry from the source). + if (!dests.empty()) { + menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); + for (std::size_t i = 0; i < dests.size(); ++i) + menuAppend(menu, kMenuMoveBase + static_cast(i), + (" " + dests[i].name).c_str()); + menuSeparator(menu); + menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); + for (std::size_t i = 0; i < dests.size(); ++i) + menuAppend(menu, kMenuCopyBase + static_cast(i), + (" " + dests[i].name).c_str()); + menuSeparator(menu); + } + menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str()); + + const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, + g_panel.hwnd, nullptr); + DestroyMenu(menu); + if (cmd == static_cast(kMenuRemove)) { + removeSamples(sel, srcId); + } else if (cmd >= static_cast(kMenuMoveBase) && + cmd < static_cast(kMenuMoveBase + dests.size())) { + transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); + } else if (cmd >= static_cast(kMenuCopyBase) && + cmd < static_cast(kMenuCopyBase + dests.size())) { + transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); + } +} + +} // namespace reasampler::panel + +// --- Public API (the selection read seam — panel_bank_ops.h) ------------------- + +namespace reasampler { + +std::vector bankPanelSelectedSampleIds() { + return panel::focusedSelectionIds(); +} + +std::string bankPanelSelectedSourceBankId() { + // The focused region's displayed bank is the move/copy source. Default to the + // pool (a safe source) when nothing is selected / the panel never opened. + if (panel::g_panel.selection.empty()) return std::string(kPoolBankId); + const std::string id = panel::bankIdForRegion(panel::g_panel.focusedRegion); + return id.empty() ? std::string(kPoolBankId) : id; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h new file mode 100644 index 0000000..6853129 --- /dev/null +++ b/src/shell/panel/panel_bank_ops.h @@ -0,0 +1,43 @@ +#pragma once +// panel_bank_ops — the bank-CRUD + selection-read seam of the bank panel (Q-W2 split +// of bank_panel.h; Phase B4/B5). The .cpp is the single home of the panel-side bank +// verbs (create / rename / delete / evacuate / activate / move / copy / remove), +// each driven against the B1 BankBook model on the session and persisted via +// persistBankOp (one bank op = one Ctrl-Z) — the owner Q-W4 dedupes actions.cpp +// against. This header carries the panel's public selection-read surface. +// +// REAPER-free: main.cpp (insert action) and actions.cpp read the selection through +// these free functions. + +#include +#include + +namespace reasampler { + +// The stable ids of the currently-selected samples, in bank (insertion) order. +// Empty when nothing is selected or the panel has never opened. This is the clean +// seam the `insert` action reads to know WHAT to place — it returns ids (not grid +// indices) so the caller resolves against the live bank and is unaffected by the +// panel's internal index bookkeeping. READ of panel state only; no mutation. +// +// Note: the panel's selection is cleared on a bank change (capture / project +// load), so a returned id always names a sample present in the current bank at +// the moment of the call; the caller still tolerates an absent id gracefully. +// +// Phase B4 (vertical split): the selection lives in whichever REGION the user last +// interacted with (the pool grid on top or a named-bank grid below), which is NOT +// necessarily the active/capture-target bank. The returned ids therefore name +// samples in the FOCUSED region's displayed bank — the bank the user visibly +// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those +// ids belong to (the move/copy source). +std::vector bankPanelSelectedSampleIds(); + +// The bank id the current selection belongs to — the displayed bank of the region +// the user last interacted with (pool region -> the pool id; named-banks region -> +// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current +// selection, and it is distinct from the active/capture-target bank (active ≠ shown). +// Returns the pool id when nothing is selected or the panel has never opened (a safe +// default source). READ of panel state only; no mutation. +std::string bankPanelSelectedSourceBankId(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp new file mode 100644 index 0000000..094e37d --- /dev/null +++ b/src/shell/panel/panel_drag.cpp @@ -0,0 +1,503 @@ +// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; the T4-01 NEW seam; M11/L7/S17). Owns WM_MOUSEMOVE +// (hover resolution + tooltip timing + the live drag), the drop-target/gesture +// classification, the cursor cues, button-up drop dispatch (reorder / replace / +// move / copy / instrument-drop / OS drag-out), and right-click menu routing. Its +// PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test) with +// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only +// the live rects, modifier state, and side effects. +// +// PER-MOUSE-MOVE GUARDRAIL (T4-28): everything on the move path stays plain +// free-function calls — no interface, no virtual dispatch. +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called +// directly here (the FX-hotspot / OS-drag / instrument-drop shells own theirs); +// REAPER SDK types arrive via panel_state.h. + +#include // std::abs (drag threshold) +#include +#include + +#include "shell/panel/panel_state.h" + +#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B) +#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) +#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) + +namespace reasampler::panel { + +// --- Drag (move between regions/onto a tab) ----------------------------------- + +constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag + +// Resolves the drop target under client (x, y) during a drag, updating dropKind / +// dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; +// anywhere else -> none. +void updateDropTarget(int x, int y) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, + static_cast(tabs.size()), kTabSpec, + g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) { + g_panel.dropKind = DropKind::Tab; + g_panel.dropBankId = tabs[static_cast(hit.index)]->id; + return; + } + // Tab takes precedence over the region; if the point is in the banks region but + // not on a specific tab, treat the whole grid as a drop zone for the shown bank. + // No valid target when there are no named banks or no shown bank. + if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { + if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { + g_panel.dropKind = DropKind::BanksRegion; + g_panel.dropBankId = g_panel.shownBankId; + return; + } + } + } + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + const RECT grid = regionGridRect(pr, false); + if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { + g_panel.dropKind = DropKind::PoolRegion; + return; + } + } +} + +// The destination bank id under the current drop target (pool id for PoolRegion; the tab/ +// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's +// dropKind/dropBankId — the single source of "what bank is under the pointer". +std::string dropTargetBankId() { + switch (g_panel.dropKind) { + case DropKind::PoolRegion: return std::string(kPoolBankId); + case DropKind::Tab: + case DropKind::BanksRegion: return g_panel.dropBankId; + case DropKind::None: return {}; + } + return {}; +} + +// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) +// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget +// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the +// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); +// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the +// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path +// in onMouseMove BEFORE this runs, so here the pointer is always inside the client. +void classifyCardDrag(int x, int y) { + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const PanelClientRect client{cr.left, cr.top, w, h}; + + const std::string destBank = dropTargetBankId(); + DragModifiers mods; + mods.ctrl = ctrlDown(); + mods.alt = altDown(); + + if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { + // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over + // in the SOURCE bank's own region display + whether it is occupied. + // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond + // the last occupied card resolves to a valid trailing slot, not a -1 miss. + mods.region = DropRegion::SameBankGrid; + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const RECT grid = regionGridRect(region, isBanks); + const int gridW = grid.right - grid.left; + const std::vector dropRects = + computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1, + gridW, kGrid); + // Translate the drop rects to client space (matching regionDisplay's translation). + std::vector dropRectsClient = dropRects; + for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; } + const int slot = hitTestSlot(x, y, dropRectsClient); + mods.targetSlot = slot; + mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty(); + g_panel.dragTargetSlot = slot; + } else if (!destBank.empty()) { + mods.region = DropRegion::OtherBankOrTab; // move/copy to a different bank/tab + } else { + mods.region = DropRegion::DeadSpace; // header/footer/gap — a no-op drop + } + + const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; + g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); +} + +// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure +// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. +// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* +// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> +// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor +// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would +// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap +// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); +// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is +// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. +void applyDragCursor(CardGesture g) { + const char* idc = IDC_ARROW; + switch (cursorForGesture(g)) { + case CursorCue::Reorder: idc = IDC_SIZEALL; break; + case CursorCue::Move: idc = IDC_HAND; break; + case CursorCue::Copy: idc = IDC_UPARROW; break; + case CursorCue::Replace: idc = IDC_SIZEWE; break; + case CursorCue::OsDragOut: return; // OS drag owns the cursor; do not fight it + case CursorCue::Default: idc = IDC_ARROW; break; + } + SetCursor(LoadCursor(nullptr, idc)); +} + +// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring +// handleClick's precedence exactly (so the element that lights on hover is the one a click +// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client +// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure +// resolution over the same pure geometry the click path uses. +Hover resolveHover(int x, int y) { + if (!g_panel.hwnd) return Hover{}; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + // TOP toolbar: the far-right More button, then the frequent buttons (matching the click + // order — first zone top-to-bottom). + { + const MenuButtonRect mb = topMenuButtonRect(w); + if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; + const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); + if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; + } + // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). + { + const int seg = footerToggleSegmentHit(x, y, w, h); + if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; + const FooterBarLayout fb = footerBarLayoutFor(w, h); + if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1}; + const ButtonRect pb = pruneButtonRectFor(w, h); + if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; + } + // BOTTOM toolbar buttons. + { + const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); + if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; + } + // Region chrome: full-height toggles, create button, tabs. + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + const RECT ftb = fullHtBtnRect(pr); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) + return Hover{HoverKind::FullHtPool, -1}; + } + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const RECT ftb = fullHtBtnRect(br); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) + return Hover{HoverKind::FullHtBanks, -1}; + const RECT cb = createBtnRect(br); + if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) + return Hover{HoverKind::CreateBank, -1}; + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), + kTabSpec, g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; + } + return Hover{}; +} + +// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no +// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also +// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only +// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the +// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. +void updateHover(int x, int y) { + const Hover next = resolveHover(x, y); + if (next != g_panel.hovered) { + g_panel.hovered = next; + g_panel.hoverSinceTick = GetTickCount(); + if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } + invalidatePanel(); + } +} + +// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past +// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the +// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; +// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the +// current hover has no tooltip (grid / chrome / the More button). +void maybeShowTooltip() { + if (g_panel.tooltipShown) return; + const HoverKind k = g_panel.hovered.kind; + if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; + const unsigned int now = GetTickCount(); + if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { + g_panel.tooltipShown = true; + invalidatePanel(); + } +} + +void onMouseMove(int x, int y) { + // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns + // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when + // the pointer is over the grid / dead space. + if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); + + if (g_panel.dragArmed && !g_panel.dragging) { + if (std::abs(x - g_panel.dragStartX) > kDragThreshold || + std::abs(y - g_panel.dragStartY) > kDragThreshold) { + // Threshold crossed — begin the drag. Snapshot the payload NOW. + g_panel.dragging = true; + g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); + g_panel.dragSampleIds = focusedSelectionIds(); + // The single card actually grabbed = the focus ordinal's id. This is the L7 + // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card + // gesture, distinct from the multi-select move/copy payload in dragSampleIds. + { + const RegionDisplay disp = focusedDisplay(); + const int f = g_panel.selection.focus; + g_panel.dragPrimaryId = + (f >= 0 && f < disp.occupiedCount()) + ? disp.orderedIds[static_cast(f)] : std::string{}; + } + g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now + g_panel.tooltipShown = false; // a drag never shows a tooltip + // SetCapture was already called at drag-arm time (handleClick); no re-capture needed. + } + } + if (g_panel.dragging) { + // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the + // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant + // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture + // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an + // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a + // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays + // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; + const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); + + DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; + st.singleCapture = (g_panel.dragSampleIds.size() == 1); + + // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can + // only arise there) and only for a single-capture payload — the SDK hit-test is skipped + // on the common internal-drag path so it costs nothing there. The screen conversion is + // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. + FxDropTarget fx; + if (!inside && st.singleCapture) { + POINT sp{x, y}; + ClientToScreen(g_panel.hwnd, &sp); + fx = resolveFxDropTarget(sp.x, sp.y); + st.overReaperUi = fx.overReaperUi; + } + + const DragGesture gesture = decideGesture(x, y, client, st); + + if (gesture == DragGesture::InstrumentDrop) { + // Track the FX hotspot for the release; the highlight is REAPER's own FX-button + // hover feedback under the pointer (the drop is driven on button-up). We keep the + // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does + // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the + // panel does not also paint a bank-drop cue while the drag is out over a track. + g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + invalidatePanel(); + return; + } + + // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. + g_panel.instrumentDropTrack = nullptr; + + if (gesture == DragGesture::OsDrag) { + // Resolve the payload to existing on-disk paths BEFORE tearing down internal + // drag state (the resolver reads dragSourceBankId / dragSampleIds). + const std::vector paths = resolveDragPathsForOs(); + + // Reset internal drag state and release capture NOW: DoDragDrop runs its own + // modal loop and takes over mouse capture, so the internal drag must be fully + // wound down first (no stale dragging/dropKind, no lingering SetCapture). A + // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state + // (invariant #2 — nothing mutated). + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + g_panel.dragArmed = false; + g_panel.dragging = false; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); + invalidatePanel(); + + // Empty path list -> nothing draggable (all stale/missing); do not start a drag. + if (!paths.empty()) + initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows + return; + } + // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing + // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ + // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. + updateDropTarget(x, y); + classifyCardDrag(x, y); + applyDragCursor(g_panel.cardGesture); + invalidatePanel(); + } +} + +// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- +// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure +// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the +// batched undo point + saves). A no-op reorder (already at the target, model returns false) +// opens no undo point. Selection reasons over slot order, so it is cleared after — the +// fingerprint pass rebuilds it against the new order. +void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { + if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; + if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point + persistBankOp("ReaSampler: reorder sample"); + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is +// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure +// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, +// no undo point (per spec). One drop = one Ctrl-Z on success. +void doReplaceDrop(const std::string& newId, const std::string& oldId, + const std::string& bankId) { + if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; + if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP + persistBankOp("ReaSampler: replace sample"); + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// Clears all drag-state fields to their resting values. Called from every exit path +// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields +// stays consistent across all four sites. +void resetDragState() { + g_panel.dragArmed = false; + g_panel.dragging = false; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); + g_panel.instrumentDropTrack = nullptr; +} + +// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: +// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. +// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). +// * None -> a drop over dead space / the source-bank gap = no-op. +// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. +void onLBtnUp(int x, int y) { + if (g_panel.dragging) { + // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a + // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, + // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank + // drop (the pointer is out over a track, not over a bank region). Single-capture only (the + // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. + if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { + const std::string sampleId = g_panel.dragSampleIds.front(); + performInstrumentDrop(g_panel.instrumentDropTrack, + buildInstrumentDropPreset(sampleId)); + // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + + // its state (both undoable in performInstrumentDrop). No book change, no ext-state, + // no dirty-mark here. + } else { + updateDropTarget(x, y); + classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) + const CardGesture g = g_panel.cardGesture; + + if (g == CardGesture::Reorder) { + doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, + g_panel.dragTargetSlot); + } else if (g == CardGesture::Replace) { + // Replace targets the OCCUPANT of the target slot with the single grabbed card. + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); + // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. + if (!occupant.empty() && occupant != g_panel.dragPrimaryId) + doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); + } else if (g == CardGesture::Move || g == CardGesture::Copy) { + const std::string destId = dropTargetBankId(); + if (!destId.empty() && destId != g_panel.dragSourceBankId && + !g_panel.dragSampleIds.empty()) { + transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, + /*copy=*/g == CardGesture::Copy); + } + } + } + // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). + SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + } else if (g_panel.dragArmed) { + // Press-release on a selected cell with no drag: treat as a plain click that + // collapses the multi-selection to the pressed cell (standard behavior). + // Release capture acquired at arm time (handleClick) — drag never started. + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); + const int count = idx ? static_cast(idx->size()) : 0; + const int focus = g_panel.selection.focus; + if (focus >= 0) + g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); + } + resetDragState(); + invalidatePanel(); +} + +// A right-click: on a named tab -> the tab management menu; on a grid cell of the +// focused region with a selection -> the move/copy menu. +void handleRightClick(int x, int y) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + // Tab management menu. + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, + static_cast(tabs.size()), kTabSpec, + g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) { + POINT pt{x, y}; + ClientToScreen(g_panel.hwnd, &pt); + showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); + return; + } + } + + // Grid selection menu (move/copy). Only when the right-click lands in the focused + // region's grid and there is a selection. + Region reg = Region::Pool; + if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && + !g_panel.selection.empty()) { + POINT pt{x, y}; + ClientToScreen(g_panel.hwnd, &pt); + showSelectionMenu(pt.x, pt.y); + } +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp new file mode 100644 index 0000000..975e460 --- /dev/null +++ b/src/shell/panel/panel_input.cpp @@ -0,0 +1,628 @@ +// panel_input.cpp — the input + detection seam of the docked bank panel (Q-W2 split +// of bank_panel.cpp). Owns left-click / wheel / keyboard routing (plain free-function +// calls on the per-event path — T4-28), the accelerator registration, the tail-setting +// read/mutate helpers, and the timer-driven new-content auto-tag detection (D2 Wave 2). +// Mouse-MOVE (hover + the card-drag state machine) lives in panel_drag; the bank-change +// fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates). +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_input.h" + +#include "actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) +#include "persist.h" // ReaSamplerSession — view/tail reads + mutation +#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) +#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) +#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) +#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation (D2/D4) + +// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane +// state to classify an item's lane as managed vs manual. +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_Main_OnCommand +#include "reaper_plugin_functions.h" + +// main.cpp owns REAPER's dispatch struct (the accelerator registers through it). +extern reaper_plugin_info_t* g_rec; + +namespace reasampler::panel { + +// The session's live tail setting (default None / 2 s when no session). Single read +// point so draw, wheel-adjust, and the capture read seam all agree on the source. +TailSetting currentTail() { + return g_panel.session ? g_panel.session->tail() : TailSetting{}; +} + +// Commits the current tail setting to ext state and marks the active project dirty +// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only +// path that calls SetProjExtState for the tail key — calling it here closes the gap +// where toggle/scroll would dirty the project but the new value was never written. +// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h). +// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way. +// NON-DESTRUCTIVE: touches nothing in the bank/arrange. +void markTailDirty() { + if (g_panel.session) g_panel.session->saveToActiveProject(); + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (proj) MarkProjectDirty(proj); +} + +// Routes a click in a toolbar to the hit button's action, fired through the command-id contract +// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click +// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the +// caller stops before grid handling. `rows` is the toolbar's inventory. +bool handleToolbarClick(int x, int y, const ActionBarRect& bar, + const std::vector& rows) { + if (bar.height <= 0) return false; + const int hit = toolbarHit(x, y, bar, rows); + if (hit < 0) { + // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through + // to the grid. Outside the band: not ours. + return y >= bar.y && y < bar.y + bar.height && + x >= bar.x && x < bar.x + bar.width; + } + const ActionBarRow& row = rows[static_cast(hit)]; + // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the + // action and never falls through to the grid (a dead button reads as inert, not absent). + if (!row.enabled) return true; + const int cmd = resolveBarCommandId(row); + if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); + return true; +} + +// --- New-content detection (D2 Wave 2) ---------------------------------------- +// +// REAPER exposes no "item/track added" callback, so we diff live project state on the +// existing timer. Each tick: enumerate every track GUID and every item GUID, diff +// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new +// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane +// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All +// enumeration is READ-ONLY on the project; the only mutation is to the in-memory +// membership index (persisted by persist on the next save, same as an action-driven tag). + +// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified +// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so +// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace. +constexpr int kFreeModeFixedLanes = 2; + +bool isFixedLaneTrack(MediaTrack* tr) { + return static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; +} + +// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): +// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies. + +// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, +// baseline input) and, for each item, records whether it sits on a manual lane so a +// newly-detected item can be exempted from auto-tag without a second project walk. +// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a +// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand +// guard) without a second project walk. +// +// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack, +// laneName) from lane_keys — the same predicate the apply path consults — so the exemption +// rule is defined in exactly one place and is unit-tested there. +void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, + std::map& itemOnManualLane, + std::map>& trackItemGuids) { + const int trackCount = CountTracks(proj); + for (int t = 0; t < trackCount; ++t) { + MediaTrack* tr = GetTrack(proj, t); + if (!tr) continue; + std::string tg = guidString(tr); + if (!tg.empty()) allGuids.insert(tg); + + // Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a + // track-level attribute and is the same for every item on the track. + const bool fixedLane = isFixedLaneTrack(tr); + + std::vector& itemsOnTrack = trackItemGuids[tg]; + const int itemCount = CountTrackMediaItems(tr); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + std::string ig = itemGuid(it); + if (ig.empty()) continue; + allGuids.insert(ig); + // Classify via the single shared predicate. For a fixed-lane track we read + // the item's lane name; for a normal track we pass "" (isOnManualLane returns + // false immediately for non-fixed-lane tracks regardless of name). + const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; + itemOnManualLane[ig] = isOnManualLane(fixedLane, ln); + itemsOnTrack.push_back(ig); + } + } +} + +// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones +// into the active mode. Runs every timer tick regardless of panel open/close (content +// is created in the arrange). READ-ONLY on the project; mutates only the in-memory +// membership index. +// +// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a +// background metadata update (like setting a label), not a destructive project edit. +// persist.cpp writes it on the next project save alongside the bank and view state, the +// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood +// the REAPER undo history with a new entry for every timer tick that sees new content. +// Returns true iff this tick tagged at least one new GUID into a mode — the signal the +// caller uses to decide whether to run the lane-minting pass (a track can only newly +// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint. +bool detectNewContent() { + if (!g_panel.session) return false; + + ReaProject* proj = EnumProjects(-1, nullptr, 0); + + // A project (re)load re-arms the first-poll guard so we never diff across two + // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded() + // on the tick persist restores the project's membership + active mode, which sets + // reloadPending. Draining it here re-baselines against the fully-loaded set (that + // same tick's reapply-active-mode enumerated those tracks, so they are present), + // and the observe() below returns nothing new — pre-existing untagged tracks stay + // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so + // no separate first-tick handling is needed here. Using persist's GUID-primary load + // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two + // identity checks can no longer diverge on a recycled ReaProject* address. + if (g_panel.reloadPending) { + g_panel.contentBaseline.reset(); + g_panel.reloadPending = false; + } + + std::set live; + std::map itemOnManualLane; + std::map> trackItemGuids; + enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); + + const std::vector added = g_panel.contentBaseline.observe(live); + if (added.empty()) return false; // first poll after open, or nothing new this tick + + ViewModeModel& model = g_panel.session->view(); + + // Which of `added` are items (the manual-lane map keys every item; track GUIDs never + // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING + // mode set — a drop plus its own new siblings must not count each other as prior. + const std::set newItemGuids = [&] { + std::set s; + for (const std::string& g : added) + if (itemOnManualLane.count(g)) s.insert(g); + return s; + }(); + + // Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings + // are found in one lookup. + std::map trackOfItem; + for (const auto& [trackGuid, items] : trackItemGuids) + for (const std::string& ig : items) trackOfItem[ig] = trackGuid; + + // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on + // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's + // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as + // planLaneMinting ignores them when computing a track's own-item mode span, so the + // adoption guard's view of the track matches the split decision's. Drives the adoption + // / strand guard in autoTagNewContent. + const auto preExistingTrackModes = + [&](const std::string& trackGuid) -> std::set { + std::set modes; + auto it = trackItemGuids.find(trackGuid); + if (it == trackItemGuids.end()) return modes; + for (const std::string& sib : it->second) { + if (newItemGuids.count(sib)) continue; // a sibling added THIS tick — not prior + auto ml = itemOnManualLane.find(sib); + if (ml != itemOnManualLane.end() && ml->second) continue; // manual lane — exempt + const std::set m = model.membership().modesOf(sib); + if (m.empty()) modes.insert(kArrangeModeId); // untagged ⇒ Arrange default + else modes.insert(m.begin(), m.end()); + } + return modes; + }; + + // Split the new GUIDs into tracks vs items so the pure decision can apply the + // manual-lane exemption to items only. A GUID present in the item-lane map is an + // item; otherwise it is a track (track GUIDs never appear in that map). + std::vector newTracks; + std::vector newItems; + for (const std::string& g : added) { + auto it = itemOnManualLane.find(g); + if (it == itemOnManualLane.end()) { + newTracks.push_back(g); // a track GUID + } else { + NewItem ni{g, it->second, {}}; + auto tk = trackOfItem.find(g); + if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second); + newItems.push_back(std::move(ni)); // an item; carries exemption + track modes + } + } + + const std::vector tags = + autoTagNewContent(newTracks, newItems, model.activeModeId()); + for (const AutoTag& tag : tags) + model.membership().tag(tag.guid, tag.modeId); + return !tags.empty(); +} + +// The item count the SELECTION reasons over — the focused region's occupied-cell count. +// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not +// selectable). Occupied count == index size by construction: every index member maps to +// exactly one occupied slot (gaps are empty slots, which the index never backs), so the +// raw index size IS the dense selection-space extent. +int focusedItemCount() { + const BankModel* idx = indexForRegion(g_panel.focusedRegion); + return idx ? static_cast(idx->size()) : 0; +} + +// --- Click routing ------------------------------------------------------------ + +// Handles a header/tab-strip/button click for the banks region. Returns true if the +// click was consumed (a region-chrome hit), false to fall through to grid selection. +bool handleBanksChromeClick(int x, int y, const RECT& region) { + // Full-height toggle button. + const RECT ftb = fullHtBtnRect(region); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { + bankPanelToggledBanksFullHeight(); + return true; + } + // "+" create button. + const RECT cb = createBtnRect(region); + if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) { + doCreateBank(); + return true; + } + // Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate). + const TabStripRect strip = banksTabStripRect(region); + const std::vector tabs = namedBanks(); + const int n = static_cast(tabs.size()); + const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll); + if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) { + const TabStripLayout layout = + computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); + const int step = kTabSpec.tabWidth; + const int desired = g_panel.tabScroll + + (hit.kind == TabHitKind::ScrollLeft ? -step : step); + g_panel.tabScroll = clampTabScroll(desired, layout); + invalidatePanel(); + return true; + } + if (hit.kind == TabHitKind::Tab) { + const Bank* bk = tabs[static_cast(hit.index)]; + if (bk->id != g_panel.shownBankId) { + g_panel.shownBankId = bk->id; // browse: show this bank's grid + g_panel.selection = Selection{}; // grid changed — reset selection + stopAudition(); + } + g_panel.focusedRegion = Region::Banks; + invalidatePanel(); + return true; + } + return false; +} + +// Handles the pool region's full-height toggle. Returns true if consumed. +bool handlePoolChromeClick(int x, int y, const RECT& region) { + const RECT ftb = fullHtBtnRect(region); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { + bankPanelToggledPoolFullHeight(); + return true; + } + return false; +} + +// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / +// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click +// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. +void handleClick(int x, int y) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right + // strip, outside the action rect), then the frequent capture/placement buttons. A button + // fires its registered action via the command-id contract; the band is claimed whole (a + // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts. + { + const MenuButtonRect mb = topMenuButtonRect(w); + if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } + } + if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return; + // Claim the WHOLE top band (including the reserved right strip between the last button and + // the More button) so a click there is inert chrome, never a fall-through to the grid. + if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return; + + // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design] + // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a + // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar / + // grid so a footer click never selects a cell. + { + const int seg = footerToggleSegmentHit(x, y, w, h); + if (seg >= 0) { + const std::vector& modes = g_panel.session->view().modes().all(); + if (seg < static_cast(modes.size())) { + applyMode(g_panel.session->view(), + modes[static_cast(seg)].id, nullptr); + invalidatePanel(); + } + return; + } + + const FooterBarLayout fb = footerBarLayoutFor(w, h); + if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { + // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates + // the SESSION's tail setting (capture reads it; persist saves it with the project) + // and marks the project dirty — touches NOTHING in the bank/arrange. + TailSetting& tail = g_panel.session->tail(); + tail.mode = cycleTailMode(tail.mode); + markTailDirty(); + invalidatePanel(); + return; + } + + // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered + // command id (fork R-E: dispatch the command, not the session directly) so the panel + // affordance and the bindable action share the one guarded dry-run/confirm/delete path + // in doBankPruneFolder. A 0 id (pre-registration) no-ops. + const ButtonRect pb = pruneButtonRectFor(w, h); + if (hitTestPruneButton(x, y, pb)) { + const int cmd = bankPruneCommandId(); + if (cmd != 0) Main_OnCommand(cmd, 0); + return; + } + } + + // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the + // command-id contract. Claimed whole like the top toolbar. + if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; + + // Region chrome (headers, tab strip, buttons). + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + if (y >= pr.top && y < regionGridRect(pr, false).top) { + if (handlePoolChromeClick(x, y, pr)) return; + } + } + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + if (y >= br.top && y < regionGridRect(br, true).top) { + if (handleBanksChromeClick(x, y, br)) return; + } + } + + // Grid selection. Resolve which region's grid the point is in. + Region reg = Region::Pool; + if (!regionAt(x, y, reg)) return; + const bool isBanks = reg == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An + // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a + // click on a gap clears selection, exactly like a click in the margin) — empty slots + // are decorative, not selectable. + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + const int hitSlot = hitTestSlot(x, y, disp.slotRects); + const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); + const int count = disp.occupiedCount(); + + // Switching focus region reseeds the selection there. + if (g_panel.focusedRegion != reg) { + g_panel.focusedRegion = reg; + g_panel.selection = Selection{}; + stopAudition(); + } + + if (hit < 0) { + if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { + g_panel.selection = Selection{}; + stopAudition(); + } + invalidatePanel(); + return; + } + + // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell: + // + // • Already-selected cell: defer the selection change to LBUTTONUP so a plain + // press on a multi-selection doesn't collapse it before we know whether a drag + // will happen. Arm the drag with the current (multi-)selection as the payload + // candidate; only the caret moves immediately. + // + // • Unselected cell: apply the plain-click selection immediately (collapses to + // the single pressed cell) THEN arm a drag from it — so the user can press-and- + // drag in one gesture without a prior selecting click. The selection is set + // before arming so that focusedSelectionIds() resolves the right payload when + // the threshold is crossed in onMouseMove. + // + // ctrl / shift presses are selection-only gestures — no drag arm in either case. + const bool onSelected = g_panel.selection.contains(hit); + if (!ctrlDown() && !shiftDown()) { + if (!onSelected) { + // Commit the single-cell selection now so the drag payload is correct. + g_panel.selection = applyClick(g_panel.selection, hit, false, false, count); + g_panel.selItemCount = count; + } else { + // Move the caret to the pressed cell; defer collapsing multi-selection. + g_panel.selection.focus = hit; + } + g_panel.dragArmed = true; + g_panel.dragStartX = x; + g_panel.dragStartY = y; + g_panel.dragSourceRegion = reg; + // Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the + // panel client rect before the drag threshold is crossed. Without capture, outside moves + // are not delivered, so a fast straight-out drag never transitions dragArmed → dragging + // and the OS drag-out never fires on the first pass. The capture is released on button-up + // (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch) + // and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState). + SetCapture(g_panel.hwnd); + invalidatePanel(); + return; + } + + g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count); + g_panel.selItemCount = count; + invalidatePanel(); +} + +// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. +// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is +// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, +// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns +// false so the caller can let REAPER/the docker handle the wheel normally). On a real +// change it mutates the SESSION's tail setting, marks the project dirty (so it saves), +// and repaints the live length. Returns true iff the wheel was consumed. +bool handleWheel(int x, int y, int delta) { + if (!g_panel.session) return false; + if (!pointInFooter(x, y)) return false; + + TailSetting& tail = g_panel.session->tail(); + if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only + + // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad + // that sends fractional deltas still steps predictably. Sign carries direction. + const int notches = delta / 120; + if (notches == 0) return false; // sub-notch movement — nothing to apply yet + + const double before = tail.manualMs; + tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); + if (tail.manualMs == before) return true; // already at a bound — consumed, no change + + markTailDirty(); + invalidatePanel(); // label shows the new length live + return true; +} + +bool isOurWindow(HWND hwnd) { + for (HWND w = hwnd; w; w = GetParent(w)) + if (w == g_panel.hwnd) return true; + return false; +} + +bool handleKey(int vk) { + const int count = focusedItemCount(); + if (count <= 0) return false; + + switch (vk) { + case VK_LEFT: + case VK_RIGHT: + case VK_UP: + case VK_DOWN: { + const NavKey nk = vk == VK_LEFT ? NavKey::Left + : vk == VK_RIGHT ? NavKey::Right + : vk == VK_UP ? NavKey::Up + : NavKey::Down; + g_panel.selection = navigate(g_panel.selection, nk, + columnsForRegion(g_panel.focusedRegion), + count, shiftDown()); + g_panel.selItemCount = count; + invalidatePanel(); + return true; + } + case VK_RETURN: + case VK_SPACE: + if (g_panel.selection.focus >= 0) + startAudition(g_panel.selection.focus); + return true; + case VK_ESCAPE: + stopAudition(); + return true; + case VK_DELETE: { + // Remove the focused-region selection (B5). Silent; a no-op when nothing + // is selected. + const std::vector sel = focusedSelectionIds(); + if (sel.empty()) return false; // nothing selected — let the key fall through + removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); + return true; + } + default: + return false; + } +} + +int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { + if (!msg || msg->message != WM_KEYDOWN) return 0; + if (!g_panel.open || !g_panel.hwnd) return 0; + if (!isOurWindow(GetFocus())) return 0; + return handleKey(static_cast(msg->wParam)) ? 1 : 0; +} + +accelerator_register_t g_accel{translateAccel, true, nullptr}; +bool g_accelRegistered = false; + +void registerAccel() { + if (g_accelRegistered || !g_rec) return; + g_rec->Register("accelerator", &g_accel); + g_accelRegistered = true; +} + +void unregisterAccel() { + if (!g_accelRegistered || !g_rec) return; + g_rec->Register("-accelerator", &g_accel); + g_accelRegistered = false; +} + +} // namespace reasampler::panel + +// --- Public API (the timer + tail read seam — panel_input.h) ------------------- + +namespace reasampler { + +void bankPanelNotifyProjectLoaded() { + // Persist restored a project's membership + active mode this tick (main.cpp calls + // this from the same consumeLoadSignal() branch that reapplies the active mode). + // Arm the new-content detector to re-baseline on its next tick so the just-loaded + // project's pre-existing content is treated as the baseline (nothing new) rather + // than diffed against the previous project and mass-tagged into the active mode. + // A flag (not an inline reset) because detectNewContent owns the baseline and runs + // later in the SAME OnTimer tick — it drains this and re-baselines against the live + // set in one place, keeping the reset and the observe() adjacent and ordered. + panel::g_panel.reloadPending = true; +} + +void bankPanelRefresh() { + // New-content auto-tag detection runs EVERY tick regardless of panel open/close: + // tracks/items are created in the arrange view, not the panel, so detection must + // not be gated on the dock being visible. READ-ONLY on the project; only mutates + // the in-memory membership index (persist saves it like any action-driven tag). + const bool tagged = panel::detectNewContent(); + + // Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a + // track can only newly become multi-mode when auto-tag placed content on it. Unlike + // the invisible membership tag above, minting is a visible structural mutation + // (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo + // block and only mints for tracks that hold >1 mode's content — a single-mode track + // is left to D1 whole-track parking. Managed lanes only; manual lanes untouched. + if (tagged && panel::g_panel.session) { + ReaProject* proj = EnumProjects(-1, nullptr, 0); + mintManagedLanes(panel::g_panel.session->view(), proj); + } + + if (!panel::g_panel.open || !panel::g_panel.hwnd) return; + + // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a + // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. + panel::maybeShowTooltip(); + + if (panel::refreshFingerprint()) + InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +capture::TailSetting bankPanelTailSetting() { + // The authoritative setting lives in the session (session->tail()) so it travels + // inside the .rpp: it loads per project and saves with the project. This stays the + // read seam for the capture actions. manualMs is clamped here so a caller always + // receives a within-cap length regardless of what was stored/scrolled. + capture::TailSetting s = panel::currentTail(); + s.manualMs = capture::clampManualMs(s.manualMs); + return s; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_input.h b/src/shell/panel/panel_input.h new file mode 100644 index 0000000..9f08280 --- /dev/null +++ b/src/shell/panel/panel_input.h @@ -0,0 +1,43 @@ +#pragma once +// panel_input — the input + detection seam of the bank panel (Q-W2 split of +// bank_panel.h). The .cpp owns mouse-click / wheel / keyboard routing (plain +// free-function calls per T4-28 — no interface on the per-event path) plus the +// timer-driven detection passes: new-content auto-tag (D2 Wave 2) and the +// hover-delay tooltip latch. This header carries the timer/lifecycle surface +// main.cpp drives and the tail-setting read seam the capture actions consume. +// +// REAPER-free as practical: TailSetting is the pure capture-side type. + +#include "core/capture/tail_control.h" // capture::TailSetting — the panel's tail-mode toggle state + +namespace reasampler { + +// Requests a repaint if the bank changed since the last paint (generation bump). +// Cheap when nothing changed. Driven by the timer so a capture / project load is +// reflected without the panel diffing the bank itself. Also hosts the every-tick +// new-content auto-tag detection (runs whether or not the dock is visible) and the +// tooltip hover-delay latch. +void bankPanelRefresh(); + +// Notifies the panel that persist just (re)loaded a project's view model (membership + +// active mode). main.cpp calls this on the exact tick it drains persist's load signal +// and reapplies the active mode. It re-arms the new-content detector so the just-loaded +// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), +// never diffed against the previously-open project and mass-tagged into the active mode. +// This coordinates the detector's project-identity signal with persist's authoritative +// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, +// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ +// arm of panel state only; no project or bank mutation. +void bankPanelNotifyProjectLoaded(); + +// The panel's current tail-mode setting (mode + Manual length), read by the plain +// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture +// applies whatever the panel toggle is set to. Default None (exact bounds) — a +// capture with no explicit choice stays byte-identical to today. The authoritative +// setting lives in ReaSamplerSession (it travels inside the .rpp); the panel mutates +// it via the footer Tail button (cycle) and scroll-wheel (Manual fine-adjust), both +// owned by this input seam. Safe to call before the panel has ever opened (returns +// the default). READ of panel state only. +capture::TailSetting bankPanelTailSetting(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp new file mode 100644 index 0000000..ca1f797 --- /dev/null +++ b/src/shell/panel/panel_layout.cpp @@ -0,0 +1,558 @@ +// panel_layout.cpp — the geometry-glue seam of the docked bank panel (Q-W2 split of +// bank_panel.cpp; the T4-01 NEW seam). Owns the toolbar/footer/menu rects, the +// toolbar row/cluster builders, the vertical-split geometry + region rects, and the +// L7 slot-order display bridge (regionDisplay/focusedDisplay). Every rect is derived +// from the client size + fullHeight state, and BOTH paint (panel_render) and +// hit-testing (panel_input / panel_drag) call these so they never drift. +// +// Also home of the public split-state seam (panel_layout.h): the B3-owned +// BankPanelFullHeight toggles the render derives the region rects from. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested (the PURE tiling / +// hit-test math lives in action_bar / footer_bar / prune_button / overflow_menu / +// tab_strip / mode_switch / bank_grid / card_drag, unit-tested outside the DAW). + +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_layout.h" + +#include "persist.h" // ReaSamplerSession — mode/view reads +#include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId() + +// Action-trigger buttons (M11): resolve each button's command id at runtime from the +// composed named-command string and read its current key binding for the tooltip. +// All main-section (SectionFromUniqueID(0)). +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_NamedCommandLookup +#define REAPERAPI_WANT_kbd_getTextFromCmd +#define REAPERAPI_WANT_SectionFromUniqueID +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +// --- Mode toggle (D5; relocated to the footer at L4) -------------------------- + +int modeCount() { + if (!g_panel.session) return 0; + return static_cast(g_panel.session->view().modes().size()); +} + +// --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- +// +// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate +// (height 0) when the client is too short to host it above the split body. The WHOLE band +// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent +// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run +// under the menu button (L5 refinement 1). + +ActionBarRect topToolbarRect(int w) { + ActionBarRect s; + s.x = 0; + s.y = 0; + s.width = w; + s.height = kTopToolbarHeight; + return s; +} + +// The band the More button occupies (the whole top toolbar band as a MenuBarRect). +MenuBarRect topMenuBarRect(int w) { + const ActionBarRect bar = topToolbarRect(w); + return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; +} + +// The More button's rect (right-anchored in the top band). Empty when the band is too narrow +// to place it clear of its left inset — the three variants stay reachable via their bindable +// commands (graceful suppression). +MenuButtonRect topMenuButtonRect(int w) { + return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); +} + +// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the +// far-right More button, so the frequent buttons never overlap it. When the More button is +// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a +// degenerate band), which keeps draw and hit-test consistent whether or not the button shows. +ActionBarRect topToolbarActionRect(int w) { + ActionBarRect bar = topToolbarRect(w); + const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); + bar.width -= reserve; + if (bar.width < 0) bar.width = 0; + return bar; +} + +// --- Footer (L4) -------------------------------------------------------------- + +RECT panelFooter(int w, int h) { + RECT rc{}; + rc.left = 0; + rc.right = w; + rc.top = h - kFooterHeight; + rc.bottom = h; + // Keep the footer below the top toolbar; if the client is too short, collapse it. + if (rc.top < kTopToolbarHeight) rc.top = rc.bottom; + return rc; +} + +// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client +// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. +FooterBarLayout footerBarLayoutFor(int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return FooterBarLayout{}; + const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; + return computeFooterBar(footer, FooterBarSpec{}); +} + +// The prune button's rect within the footer, derived from the client size. SINGLE source +// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate +// or too narrow to place the button clear of the footer-left group / version readout — the +// action stays reachable via its bindable command, so a suppressed button is graceful. Kept +// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two +// groups never overlap). See prune_button.h §Placement contract. +ButtonRect pruneButtonRectFor(int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button + const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; + return computePruneButton(footer, PruneButtonSpec{}); +} + +// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the +// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The +// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). +bool pointInFooter(int x, int y) { + if (!g_panel.hwnd) return false; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); + return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; +} + +// === Task-grouped toolbars (Phase L, L2 + L4) ================================= +// +// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) +// across TWO toolbars, BOTH drawn through the pure action_bar module: +// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the +// two acts the tool exists for (L4 §1); +// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the +// footer, in the space capture/placement vacated (L4 §2). +// Each button is drawn with its action name (Font::Label) and live key binding on a Micro +// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects, +// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query +// + the NamedCommandLookup/Main_OnCommand dispatch live here. +// +// Each button resolves its command id at RUNTIME from the composed named-command string +// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable +// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) +// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the +// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts. + +// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track) +// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then +// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor / +// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) — +// same registered actions, same command-id contract, just a different home. Capture scopes come +// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8 +// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name +// for the hover tooltip. +std::vector topBarRows() { + std::vector rows; + // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; + // the tooltip carries the full descriptionPhrase the action was registered with. + for (const CaptureActionDef& def : captureActionTable()) { + std::string label = def.commandSuffix; + if (label == "CAPTURE_ITEM") label = "Capture Item"; + else if (label == "CAPTURE_TRACK") label = "Capture Track"; + rows.push_back({def.commandSuffix, label, def.descriptionPhrase, + ActionCluster::Capture, true}); + } + // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and + // the placement group so its position reads "refine the last capture before placing it". + // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6). + rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", + "re-capture from source", ActionCluster::Maintenance, true}); + // Placement cluster — the second act (still a distinct on-demand act; no auto-insert). + rows.push_back({"INSERT_SELECTED", "Insert", + "insert selected sample at edit cursor", ActionCluster::Placement, true}); + rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", + "insert selected sample at edit cursor (conform to tempo)", + ActionCluster::Placement, true}); + return rows; +} + +// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the +// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus +// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered +// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The +// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has +// room for the full name). Batch entries first, then the two realtime verbs. +std::vector overflowMenuRows() { + return { + {"CAPTURE_BATCH_ITEMS", "Batch Items", + "batch capture selected items (one per item)", ActionCluster::Capture, true}, + {"CAPTURE_BATCH_RAZOR", "Batch Razor", + "batch capture razor areas (one per area)", ActionCluster::Capture, true}, + {"CAPTURE_TRACK_REALTIME", "Capture RT", + "capture selected track (realtime)", ActionCluster::Capture, true}, + {"CANCEL_REALTIME_CAPTURE", "Cancel RT", + "cancel realtime capture", ActionCluster::Maintenance, true}, + }; +} + +// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for +// "which mode is active"). Empty when no session (every button then falls to fail-open live). +std::string activeModeIdOrEmpty() { + if (!g_panel.session) return {}; + return g_panel.session->view().activeModeId(); +} + +// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons +// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from +// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; +// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not +// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). +// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the +// footer [Arrange|Design] toggle owns mode switching. +// +// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode +// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from +// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw +// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the +// Track button for a target share the target's enablement. +std::vector bottomBarRows() { + const std::string active = activeModeIdOrEmpty(); + const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); + const bool designLive = tagButtonEnabled(active, TagTarget::Design); + + std::vector rows; + // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. + rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", + "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); + rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", + "move selected items -> Design", ActionCluster::Tagging, designLive}); + rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange", + "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); + rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", + "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); + // Switching cluster — Show Both, set apart (the only survivor of the old switching group). + rows.push_back({"VIEW_SHOW_BOTH", "Show Both", + "show both for selected tracks", ActionCluster::Switching, true}); + return rows; +} + +// The cluster button-count specs for a given row set, in the row list's cluster order (so the +// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds; +// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec +// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement — +// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging, +// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there. +std::vector actionBarClusters(const std::vector& rows) { + int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; + for (const ActionBarRow& r : rows) { + switch (r.cluster) { + case ActionCluster::Capture: ++nCap; break; + case ActionCluster::Placement: ++nPlace; break; + case ActionCluster::Maintenance: ++nMaint; break; + case ActionCluster::Tagging: ++nTag; break; + case ActionCluster::Switching: ++nSwitch; break; + } + } + return { + {ActionCluster::Capture, nCap}, + {ActionCluster::Maintenance, nMaint}, + {ActionCluster::Placement, nPlace}, + {ActionCluster::Tagging, nTag}, + {ActionCluster::Switching, nSwitch}, + }; +} + +// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split +// body). Degenerate (height 0) when the client is too short to host it above the footer. +ActionBarRect bottomToolbarRect(int w, int h) { + ActionBarRect s; + const RECT footer = panelFooter(w, h); + const int footerTop = (footer.top < footer.bottom) ? footer.top : h; + s.x = 0; + s.width = w; + s.height = kBottomToolbarHeight; + s.y = footerTop - kBottomToolbarHeight; + // Keep the bar below the top toolbar; if the client is too short, collapse it. + if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } + return s; +} + +// Resolves a row's composed named command to its runtime command id (0 if not registered). +// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). +int resolveBarCommandId(const ActionBarRow& row) { + if (!NamedCommandLookup) return 0; + const std::string named = "_" + channelCommandId(row.suffix); + return NamedCommandLookup(named.c_str()); +} + +// The current key binding string for a command in the MAIN section, or "" (unbound / not +// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). +std::string barBindingText(int cmd) { + if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { + const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); + if (t) return std::string(t); + } + return {}; +} + +// The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. +int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { + if (bar.height <= 0) return -1; + return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec); +} + +// --- Tooltip (L5 refinement 2) ------------------------------------------------ +// +// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar +// button. Resolves the hovered element to its (anchor rect, text); returns false when the current +// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its +// affordance). The tooltip DRAW lives in panel_render (drawTooltip); timing +// (kTooltipDelayMs) is applied by the caller. + +// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its +// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button. +bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) { + const Hover& hv = g_panel.hovered; + std::vector rows; + ActionBarRect bar{}; + if (hv.kind == HoverKind::TopBarButton) { + rows = topBarRows(); + bar = topToolbarActionRect(w); + } else if (hv.kind == HoverKind::BottomBarButton) { + rows = bottomBarRows(); + bar = bottomToolbarRect(w, h); + } else { + return false; + } + if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; + + // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + + // hit-test use, so the anchor matches the drawn button exactly. + const std::vector clusters = actionBarClusters(rows); + const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); + const ActionBarSlot* slot = nullptr; + for (const ActionBarSlot& s : slots) + if (s.index == hv.index) { slot = &s; break; } + if (!slot) return false; + + // The full name is stored already prefix-free, but strip defensively in case a source ever + // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). + // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries + // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the + // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip). + const std::string phrase = stripActionPrefix(rows[static_cast(hv.index)].fullName, + actionDisplayPrefix()); + const int cmd = resolveBarCommandId(rows[static_cast(hv.index)]); + const std::string binding = barBindingText(cmd); + textOut = binding.empty() ? phrase : phrase + " \xe2\x80\x94 " + binding; // " — " (em dash, UTF-8) + ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height; + return true; +} + +// --- Split geometry ----------------------------------------------------------- +// +// Every rect below is derived from the client size + fullHeight state, and BOTH paint +// and hit-testing call these so they never drift. All are top-left origin. + +// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the +// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a +// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there. +RECT splitBody(int w, int h) { + RECT rc{}; + rc.left = 0; + rc.right = w; + rc.top = kTopToolbarHeight; + const ActionBarRect bar = bottomToolbarRect(w, h); + rc.bottom = bar.y; + if (rc.bottom < rc.top) rc.bottom = rc.top; + return rc; +} + +// True when both regions are shown (the split is live). Otherwise one region fills +// the body. +bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } +bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } + +// The pool region's rect (whole-region: header band + grid). Empty when hidden. +RECT poolRegionRect(int w, int h) { + const RECT body = splitBody(w, h); + if (!poolShown()) return RECT{0, 0, 0, 0}; + if (!banksShown()) return body; // pool full-height: the whole body + // Split: pool gets the top half (minus the divider). + RECT rc = body; + rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; + if (rc.bottom < rc.top) rc.bottom = rc.top; + return rc; +} + +// The named-banks region's rect (whole-region: header band + tab strip + grid). +RECT banksRegionRect(int w, int h) { + const RECT body = splitBody(w, h); + if (!banksShown()) return RECT{0, 0, 0, 0}; + if (!poolShown()) return body; // banks full-height: the whole body + RECT rc = body; + rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 + + kSplitDividerHeight; + if (rc.top > rc.bottom) rc.top = rc.bottom; + return rc; +} + +// A region's header band (the top kRegionHeaderHeight of the region). +RECT regionHeaderRect(const RECT& region) { + RECT rc = region; + rc.bottom = region.top + kRegionHeaderHeight; + if (rc.bottom > region.bottom) rc.bottom = region.bottom; + return rc; +} + +// The named-banks region's tab strip (below its header band). +TabStripRect banksTabStripRect(const RECT& region) { + const RECT hdr = regionHeaderRect(region); + TabStripRect s; + s.x = region.left; + s.y = hdr.bottom; + s.width = region.right - region.left; + s.height = kTabStripHeight; + if (s.y + s.height > region.bottom) s.height = region.bottom - s.y; + if (s.height < 0) s.height = 0; + return s; +} + +// A region's grid viewport (below the header band, and below the tab strip for the +// banks region). This is where cells tile. +RECT regionGridRect(const RECT& region, bool isBanks) { + RECT rc = region; + rc.top = region.top + kRegionHeaderHeight; + if (isBanks) rc.top += kTabStripHeight; + if (rc.top > rc.bottom) rc.top = rc.bottom; + return rc; +} + +// The full-height toggle button rect inside a region header (right-aligned). +RECT fullHtBtnRect(const RECT& region) { + const RECT hdr = regionHeaderRect(region); + RECT rc = hdr; + rc.right = hdr.right - 4; + rc.left = rc.right - kFullHtBtnWidth; + rc.top = hdr.top + 2; + rc.bottom = hdr.bottom - 2; + return rc; +} + +// The "+" create-bank button rect inside the named-banks region header (left of the +// full-height button). +RECT createBtnRect(const RECT& region) { + RECT ft = fullHtBtnRect(region); + RECT rc = ft; + rc.right = ft.left - 4; + rc.left = rc.right - kCreateBtnWidth; + return rc; +} + +// Resolves a region's display for the currently-shown bank. Empty (no bank / no width) +// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live +// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete +// order (trailing empties are trimmed by the model — maxSlot walks only live occupants). +RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { + RegionDisplay d; + BankBook* b = book(); + if (!b) return d; + const std::string bankId = bankIdForRegion(reg); + if (bankId.empty()) return d; + d.bank = b->bank(bankId); + if (!d.bank) return d; + + d.orderedIds = b->orderedSampleIds(bankId); // occupied ids, slot order (reconciles) + if (d.orderedIds.empty()) return d; + + const RECT grid = regionGridRect(region, isBanks); + const int w = grid.right - grid.left; + if (w <= 0) return d; + d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid); + for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; } + return d; +} + +// The FOCUSED region's display (the slot-order bridge for the region holding the live +// selection). Mirrors columnsForRegion's client read. +RegionDisplay focusedDisplay() { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const bool isBanks = g_panel.focusedRegion == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + return regionDisplay(region, isBanks, g_panel.focusedRegion); +} + +// Which region (if any) contains client point (x, y); returns false via `out` set to +// Pool by default when the point is in neither region body. +bool regionAt(int x, int y, Region& out) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + if (poolShown()) { + const RECT r = poolRegionRect(w, h); + if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { + out = Region::Pool; return true; + } + } + if (banksShown()) { + const RECT r = banksRegionRect(w, h); + if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { + out = Region::Banks; return true; + } + } + return false; +} + +// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by +// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box. +int footerToggleSegmentHit(int x, int y, int w, int h) { + if (!g_panel.session) return -1; + const FooterBarLayout fb = footerBarLayoutFor(w, h); + if (fb.toggle.empty()) return -1; + const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; + return hitTestSegment(x, y, th, modeCount()); +} + +// The column count for a region's current grid width (nav needs the layout's wrap). +int columnsForRegion(Region reg) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const RECT region = reg == Region::Banks ? banksRegionRect(w, h) + : poolRegionRect(w, h); + const RECT grid = regionGridRect(region, reg == Region::Banks); + return columnsForWidth(grid.right - grid.left, kGrid); +} + +} // namespace reasampler::panel + +// --- Public API (the split-state seam — panel_layout.h) ------------------------ + +namespace reasampler { + +BankPanelFullHeight bankPanelFullHeight() { + return panel::g_panel.fullHeight; +} + +static void setFullHeight(BankPanelFullHeight target) { + panel::g_panel.fullHeight = + (panel::g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; + if (panel::g_panel.hwnd) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelToggledPoolFullHeight() { + setFullHeight(BankPanelFullHeight::PoolOnly); +} + +void bankPanelToggledBanksFullHeight() { + setFullHeight(BankPanelFullHeight::BanksOnly); +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_layout.h b/src/shell/panel/panel_layout.h new file mode 100644 index 0000000..ef81c1a --- /dev/null +++ b/src/shell/panel/panel_layout.h @@ -0,0 +1,43 @@ +#pragma once +// panel_layout — the vertical-split layout STATE seam of the bank panel (Q-W2 split of +// bank_panel.h; Phase B3/B4). The panel window splits vertically — pool on top, +// named-banks region below — and two toggles collapse the split. This header carries +// that public state surface; the geometry derivation itself (toolbar/footer/menu rects, +// row/cluster builders, region rects, the L7 slot-order display bridge) is internal to +// panel_layout.cpp (see panel_state.h for the intra-panel seam). +// +// REAPER-free: main.cpp / actions.cpp drive these through plain free functions. + +namespace reasampler { + +// The vertical-split full-height layout state (Phase B). The bank window splits +// vertically — pool on top, named-banks region below — and two toggles collapse the +// split: pool full-height (hide the named-banks region) and banks full-height (hide +// the pool). The two are mutually exclusive with the default (both regions shown), +// so one enum captures the whole state. +// +// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives +// beside the tail setting — the other session-level view-layout bit the panel +// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not +// project state, so it must not travel with the .rpp. In-memory for the extension's +// lifetime; resets to Split on unload. +enum class BankPanelFullHeight { + Split, // default: pool region on top, named-banks region below + PoolOnly, // pool full-height — named-banks region hidden + BanksOnly, // banks full-height — pool region hidden +}; + +// The current full-height layout state (default Split). READ by B4's panel to decide +// which region(s) to draw. Safe before the panel has ever opened. +BankPanelFullHeight bankPanelFullHeight(); + +// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from +// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool +// full-height" action. Requests a repaint so an open panel reflects the change. +void bankPanelToggledPoolFullHeight(); + +// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. +// Bound to the "banks full-height" action. Requests a repaint. +void bankPanelToggledBanksFullHeight(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp new file mode 100644 index 0000000..6f17d32 --- /dev/null +++ b/src/shell/panel/panel_render.cpp @@ -0,0 +1,609 @@ +// panel_render.cpp — the LICE draw seam of the docked bank panel (Q-W2 split of +// bank_panel.cpp; M5 Wave A/B + Phase B4 + Phase L). Owns WM_PAINT's full paint: +// the VERTICAL SPLIT (pool grid region on top, named-banks tab-page region below), +// the region headers + tab strip, the two task-grouped toolbars + More button, the +// footer (mode toggle + count + Tail + Prune), the hover-delay tooltip overlay, and +// the per-card thumbnail/metadata draw — everything through the L1 kit by palette +// role (draw_kit), double-buffered, BitBlt'd once. +// +// READ-ONLY: reads panel + session state; the input/drag seams mutate it. All rect +// derivation comes from panel_layout (the single source both draw and hit-test use). +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called +// here (LICE/Win32 only); REAPER SDK types arrive via panel_state.h. + +#include +#include + +#include "shell/panel/panel_state.h" + +#include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1) +#include "persist.h" // ReaSamplerSession — mode/view/tail reads +#include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model + +namespace reasampler::panel { + +// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- + +// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT +// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT +// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's +// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical +// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string. +void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { + MusicalLength ml; + ml.lengthSeconds = s.lengthSeconds; + ml.tempoBpm = s.captureTempo; + ml.timeSigNum = s.captureTimeSigNum; + ml.timeSigDenom = s.captureTimeSigDenom; + const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo + const std::string secs = formatSecondsMs(s.lengthSeconds); + + // A short strip along the card's bottom edge. Left/right halves; text/dim so the + // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right + // (tabular numbers that must not jitter). + const int stripH = 12; + const int pad = 3; + const int y = rect.y + rect.height - stripH; + if (!bars.empty()) { + const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH}; + text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left); + } + const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH}; + text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right); +} + +void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, + bool selected, bool focused, bool hovered, const Sample* sample) { + // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL + // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is + // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill- + // state change orthogonal to that border, so a hovered selected card still reads selected. + const KitBox cell{rect.x, rect.y, rect.width, rect.height}; + const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest; + fillSurface(bmp, cell, Role::BgCell, state); + + // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else + // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card + // reads BOTH — the purple outer border + the inner focus ring — kept visually separate. + const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); + if (focused) { + const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); + LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); + } + + // Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column + // min/max envelope draw the VST editor hero + browser cards use — one algorithm, one + // look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per + // column via peaks::columnMinMax inside the kit; an empty env draws just the midline. + drawWaveform(bmp, cell, env); + + // L7 decorative metadata overlay, drawn last so it sits over the waveform. + if (sample) drawCardMeta(bmp, rect, *sample); +} + +// --- Kit draw adapters (Phase L) ---------------------------------------------- +// +// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText +// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces +// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based +// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for +// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle +// (kitFontsInit/Shutdown, wired at panel open/close below). + +KitBox toKitBox(const RECT& r) { + return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; +} + +// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single +// conversion boundary the kit enforces. No local alias needed. + +// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit +// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for +// region headings). Takes a KitBox directly (the pure geometry the L2 modules return). +void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, + Font font, Role role, Align align) { + text(bmp, box, txt, font, role, align); +} + +// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves +// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no +// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) +// A display-only tally over the model's public membership map — no model semantics duplicated. +int activeModeMemberCount() { + if (!g_panel.session) return 0; + const ViewModeModel& view = g_panel.session->view(); + const std::string& active = view.activeModeId(); + if (active.empty()) return 0; + int n = 0; + for (const auto& [guid, m] : view.membership().all()) + if (m.modeIds.count(active) != 0) ++n; + return n; +} + +// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] +// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and +// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button +// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. +void drawFooter(LICE_IBitmap* bmp, int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return; + + // Footer band + hairline top divider (the base persistent-controls strip). + fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, + InteractionState::Rest); + LICE_Line(bmp, f.left, f.top, f.right, f.top, + toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); + + const FooterBarLayout fb = footerBarLayoutFor(w, h); + + // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box + // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The + // active mode's segment carries the accent; others hover-or-rest bg/cell. + if (!fb.toggle.empty() && g_panel.session) { + const ViewModeModel& view = g_panel.session->view(); + const std::vector& modes = view.modes().all(); + const int n = static_cast(modes.size()); + const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; + const std::vector segs = computeSegmentRects(th, n); + const std::string& activeId = view.activeModeId(); + for (int i = 0; i < static_cast(segs.size()); ++i) { + const SegmentRect& s = segs[static_cast(i)]; + const Mode& mode = modes[static_cast(i)]; + const bool active = mode.id == activeId; + const InteractionState state = + active ? InteractionState::Active + : hoverState(g_panel.hovered, HoverKind::ModeSegment, i); + fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state); + LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + const Role tr = active ? Role::BgBase : Role::TextPrimary; + kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(), + Font::Label, tr, Align::Center); + } + } + + // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count + // travels with the toggle"). Passive text, not a control. + if (!fb.count.empty()) { + const int members = activeModeMemberCount(); + const std::string countLabel = + std::to_string(members) + (members == 1 ? " track" : " tracks"); + kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height}, + countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); + } + + // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the + // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel. + if (!fb.tail.empty()) { + const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1); + const std::string label = tailToggleLabel(currentTail()); + const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}}; + drawButton(bmp, box, label.c_str(), state, /*warn=*/false); + } + + // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion() + // renders the configured version string on stable and that string plus "-beta" on beta, + // so a beta panel self-identifies. It sits inside the space footer_bar reserves at the + // right (rightReserve) and clears the prune button (prune_button::rightInset). Dim, + // passive identification (V3). + kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top}, + appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right); + + // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control), + // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign, + // frequent) -> right (destructive, rare) per the L4 footer contract. + const ButtonRect pb = pruneButtonRectFor(w, h); + if (!pb.empty()) { + const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); + const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}}; + drawButton(bmp, box, "Prune", state, /*warn=*/true); + } +} + +// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button +// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face. +// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so +// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use +// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider` +// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body); +// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip +// (L6), not on the button face — the face shows only shortLabel. +void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, + const std::vector& rows, HoverKind hoverKind, bool topDivider) { + if (bar.height <= 0 || bar.width <= 0) return; + + const KitBox band{bar.x, bar.y, bar.width, bar.height}; + fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest); + const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1; + LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY, + toLice(roleColor(Role::LineHairline)), 0.5f, 0, false); + + const std::vector clusters = actionBarClusters(rows); + const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); + + for (const ActionBarSlot& s : slots) { + if (s.index < 0 || s.index >= static_cast(rows.size())) continue; + const ActionBarRow& row = rows[static_cast(s.index)]; + const int cmd = resolveBarCommandId(row); + + // State: Disabled when the action is not registered on this channel OR the row is gated + // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover + // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) + InteractionState state = InteractionState::Rest; + if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; + else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) + state = InteractionState::Hover; + + // The button surface (drawButton draws the micro-gradient + rounded border + honors + // the state). The label is drawn separately so the text role tracks the state correctly; + // pass no label to drawButton. + const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; + drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); + + const Role textRole = + (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary; + const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH}; + kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center); + } +} + +// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- +// +// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ +// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the +// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand +// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine +// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. + +// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). +void drawMoreButton(LICE_IBitmap* bmp, int w) { + const MenuButtonRect mb = topMenuButtonRect(w); + if (mb.empty()) return; + const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); + const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; + drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); + // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text + // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". + kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", + Font::Label, Role::TextPrimary, Align::Center); +} + +// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current +// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the +// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the +// anchor, flipping above near the bottom edge, clamped to the client). +void drawTooltip(LICE_IBitmap* bmp, int w, int h) { + if (!g_panel.tooltipShown) return; + std::string txt; + int ax = 0, ay = 0, aw = 0, ah = 0; + if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return; + + const int textW = static_cast(txt.size()) * kTooltipCharPx; + const TooltipBox tb = + computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{}); + if (tb.empty()) return; + + // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text. + const KitBox box{tb.x, tb.y, tb.width, tb.height}; + fillSurface(bmp, box, Role::BgCell, InteractionState::Hover); + LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); +} + +// --- Drawing: a grid region --------------------------------------------------- + +// Draws one region's grid of thumbnails (or an empty-state line) clipped to its +// viewport. `selectionOwner` is true when this region holds the live selection, so +// its cells show selection/focus chrome; the other region draws plain. +void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, + const BankModel* index, const std::string& emptyMsg, + bool selectionOwner, const std::string& projectDir, Region reg) { + const RECT grid = regionGridRect(region, isBanks); + if (grid.bottom <= grid.top) return; + + if (!index || index->empty()) { + kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center); + return; + } + + // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense + // BankModel insertion order. Selection/focus are keyed by the occupied-ordinal (selection + // space); a slot maps back to its ordinal via selectionForSlot. + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's + // peaks::columnMinMax exact partition makes every column gap-free — overbinning + // produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps + // the request to the frame count. + const int binWidth = kWaveformOversample * + waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight}); + for (const SlotCellRect& r : disp.slotRects) { + if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll) + const CellRect rect{r.x, r.y, r.width, r.height}; + const std::string id = disp.idAtSlot(r.slot); + if (id.empty()) { + // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline + // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No + // selection/focus/waveform, and not a hover or hit target (the grid never tracks + // cell hover; a click on an empty slot clears selection like any grid miss). + fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, + Role::BgCell, InteractionState::Rest); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + continue; + } + const Sample* s = index->query(id); + if (!s) continue; // reconciled order should never name a stale id; defensive + const int sel = disp.selectionForSlot(r.slot); + const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel); + const bool focused = selectionOwner && sel >= 0 && g_panel.selection.focus == sel; + const Envelope& env = thumbnailFor(*s, binWidth, projectDir); + // Grid-cell hover is intentionally not tracked: the cell already carries selection + + // focus chrome (the centerpiece's "bones"); a third transient hover state on every + // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs. + drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, s); + } +} + +// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but +// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag +// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection +// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline +// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op +// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). +void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { + if (!g_panel.dragging) return; + if (g_panel.cardGesture != CardGesture::Reorder && + g_panel.cardGesture != CardGesture::Replace) + return; + if (g_panel.dragSourceRegion != reg) return; // highlight only the source bank's grid + if (g_panel.dragTargetSlot < 0) return; + + const RECT grid = regionGridRect(region, isBanks); + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent + // target slot gets a visible highlight cue, not silence. + const int gridW = grid.right - grid.left; + const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1; + std::vector dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid); + for (SlotCellRect& r : dropRects) { r.x += grid.left; r.y += grid.top; } + for (const SlotCellRect& r : dropRects) { + if (r.slot != g_panel.dragTargetSlot) continue; + if (r.y >= grid.bottom) return; // below the viewport (no scroll) + const LICE_pixel hot = toLice(roleColor(Role::AccentHot)); + LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0); + if (g_panel.cardGesture == CardGesture::Replace) + LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0); + return; + } +} + +// Draws a region header: title, the active-bank readout, and the full-height button. +void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, + const std::string& activeName, bool poolBtnIsPool) { + const RECT hdr = regionHeaderRect(region); + // Region header band (kit bg/panel — a raised region title bar). A hairline underline. + fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, + Role::BgPanel, InteractionState::Rest); + LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, + toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); + + // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of + // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary + // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is + // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:" + // readout beside it), keeping primary reserved for the live/active layer. + RECT titleRc = hdr; + titleRc.left += 8; + titleRc.right = titleRc.left + 120; + const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; + kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); + + // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY + // accent role in BOTH region headers so the active/capture-target bank is legible even when + // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). + const std::string readout = "Active: " + activeName; + RECT actRc = hdr; + actRc.left = titleRc.right + 6; + actRc.right = createBtnRect(region).left - 6; + if (actRc.right > actRc.left) + kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); + + // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; + // when this region is already full it means "restore the split". Kit drawButton + hover. + const RECT btn = fullHtBtnRect(region); + const bool thisFull = + poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) + : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); + const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks; + const InteractionState state = + thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1); + const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left, + btn.bottom - btn.top}}; + drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false); +} + +// Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN +// tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow +// chevrons when present, plus the "+" create button in the header. During a drag, +// the tab under the pointer gets the drop-target highlight. +void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { + const TabStripRect strip = banksTabStripRect(region); + if (strip.height <= 0) return; + // Tab strip band (kit bg/base — recessed relative to the region header above it). + fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, + Role::BgBase, InteractionState::Rest); + + const std::vector tabs = namedBanks(); + const int n = static_cast(tabs.size()); + if (n == 0) { + kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height}, + "No named banks -- click + to create one.", + Font::Label, Role::TextDim, Align::Left); + return; + } + + const TabStripLayout layout = + computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); + + // Chevrons (drawn first so tabs sit above their inner edges). + if (layout.overflow) { + const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height}; + const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, + kTabSpec.chevronWidth, strip.height}; + fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest); + fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest); + kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center); + kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center); + } + + const std::string activeId = book() ? book()->activeBankId() : std::string(); + const std::vector rects = + computeTabRects(strip, n, kTabSpec, g_panel.tabScroll); + for (const TabRect& tr : rects) { + const Bank* bk = tabs[static_cast(tr.index)]; + const bool shown = bk->id == g_panel.shownBankId; + const bool active = bk->id == activeId; + const bool dropHere = g_panel.dragging && + g_panel.dropKind == DropKind::Tab && + g_panel.dropBankId == bk->id; + const bool hovered = g_panel.hovered.kind == HoverKind::Tab && + g_panel.hovered.index == tr.index; + + // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag + // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); + // else hover-or-rest bg/cell. + const KitBox tb{tr.x, tr.y, tr.width, tr.height}; + InteractionState state = InteractionState::Rest; + if (active) state = InteractionState::Active; + else if (dropHere) state = InteractionState::Dragging; + else if (shown) state = InteractionState::Pressed; + else if (hovered) state = InteractionState::Hover; + fillSurface(bmp, tb, Role::BgCell, state); + + // The active bank's tab gets a bright accent border (unmistakable), distinct from the + // shown tab's fill — active != shown, made visible (kit accent role). + const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0); + if (active) + LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, + toLice(border), 1.0f, 0); + + // Label: bg/base on the accent-active fill for contrast, else text/primary. + const Role trole = active ? Role::BgBase : Role::TextPrimary; + kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height}, + bk->displayName.c_str(), Font::Label, trole, Align::Center); + } +} + +// The active bank's display name (for the readout). "Pool" when the pool is active. +std::string activeBankName() { + BankBook* b = book(); + if (!b) return std::string(kPoolBankName); + const Bank* bk = b->bank(b->activeBankId()); + return bk ? bk->displayName : std::string(kPoolBankName); +} + +// --- Full paint --------------------------------------------------------------- + +void paintPanel(HWND hwnd, HDC hdc) { + RECT cr{}; + GetClientRect(hwnd, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + LICE_SysBitmap bmp(w, h); + LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); + + const std::string projectDir = currentProjectDir(); + const std::string activeName = activeBankName(); + + // Pool region (top). + if (poolShown()) { + const RECT region = poolRegionRect(w, h); + drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); + drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), + "No samples in the pool yet. Capture one to see it here.", + g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); + // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid + // outline signalling "drop here to move/copy into this bank"). Suppressed for a + // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). + if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && + (g_panel.cardGesture == CardGesture::Move || + g_panel.cardGesture == CardGesture::Copy)) { + const RECT grid = regionGridRect(region, false); + LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, + grid.right - grid.left - 2, grid.bottom - grid.top - 2, + toLice(roleColor(Role::AccentHot)), 1.0f, 0); + } + // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline + // on the target slot's cell — distinct from the accent/tertiary purple selection + // border, so it is never confusable with a selected card. + drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); + } + + // Split divider. + if (poolShown() && banksShown()) { + const RECT body = splitBody(w, h); + const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; + LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, + toLice(roleColor(Role::BgBase)), 1.0f, 0); + } + + // Named-banks region (bottom). + if (banksShown()) { + const RECT region = banksRegionRect(w, h); + drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); + // "+" create button (drawn as part of the banks header) — kit drawButton + hover. + const RECT cbtn = createBtnRect(region); + const InteractionState createState = + hoverState(g_panel.hovered, HoverKind::CreateBank, -1); + drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left, + cbtn.bottom - cbtn.top}}, + "+", createState, /*warn=*/false); + + drawTabStrip(&bmp, region); + drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), + g_panel.shownBankId.empty() + ? "Select or create a named bank." + : "This bank is empty. Move samples here from the pool.", + g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); + // Drop-target highlight for the banks region during a drag. BanksRegion fires + // when the pointer is in the grid but not on a specific tab; Tab draws its own + // highlight on the individual tab (drawTabStrip above handles that case). + if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && + (g_panel.cardGesture == CardGesture::Move || + g_panel.cardGesture == CardGesture::Copy)) { + const RECT grid = regionGridRect(region, true); + LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, + grid.right - grid.left - 2, grid.bottom - grid.top - 2, + toLice(roleColor(Role::AccentHot)), 1.0f, 0); + } + // L7 per-slot reorder/replace target highlight (source = banks region). + drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); + } + + // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles + // into the band MINUS the far-right More-button reserve; the More button is drawn over the + // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show + // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit + // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the + // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. + fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); + drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, + /*topDivider=*/false); + drawMoreButton(&bmp, w); + drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton, + /*topDivider=*/true); + drawFooter(&bmp, w, h); + + // The custom hover-delay tooltip overlays everything (L5 refinement 2). + drawTooltip(&bmp, w, h); + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h new file mode 100644 index 0000000..f0e265f --- /dev/null +++ b/src/shell/panel/panel_state.h @@ -0,0 +1,606 @@ +#pragma once +// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel +// (Q-W2: bank_panel.cpp split into eight TUs under shell/panel/). Included ONLY by the +// panel's own translation units (panel_render / panel_thumbnails / panel_audition / +// panel_input / panel_layout / panel_drag / panel_bank_ops / panel_window) — consumers +// outside the panel use the per-seam public headers (panel_window.h / panel_input.h / +// panel_bank_ops.h / panel_layout.h). +// +// What lives here: +// * PanelState (the one shared state blob, defined in panel_window.cpp) + the small +// enums/structs the seams speak (Region / DropKind / Hover / RegionDisplay / +// ActionBarRow) and the shared layout constants. +// * The cross-seam free-function declarations, grouped by OWNING TU. Everything is a +// plain free function — direct call-through, no interface, no virtual dispatch +// (T4-28: the audition path and the per-mouse-move path must stay direct calls). +// * Explicit using-declarations pulling the pure modules' symbols into +// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — the +// panel TUs do NOT include the interim core/namespaces.h shim (Q-W2 retires it +// for this module; some still-unsplit shell headers carry it transitively until +// their own waves, but nothing here depends on it). +// +// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural +// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, +// so a BankModel& / Bank* must NEVER be cached across one. Every seam resolves fresh +// AFTER any mutation and passes bank IDS (not references) into the model ops. + +#include +#include +#include +#include + +// SWELL / platform types (HWND, RECT, HMENU). On macOS/Linux SWELL is provided by the +// host (SWELL_PROVIDED_BY_APP); on Windows we use native Win32 (windows.h first, then +// swell.h no-ops on _WIN32). +#ifdef _WIN32 +#include +#else +#include +#endif +#include "wdltypes.h" +#include "swell/swell.h" + +// REAPER SDK types only (preview_register_t, MediaTrack, ReaProject). The API function +// POINTERS are declared per-TU (REAPERAPI_MINIMAL + per-TU WANT list) — main.cpp owns +// the definitions (CLAUDE.md §contract). +#include "reaper_plugin.h" + +#include "core/audio/peaks.h" // audio::Envelope — thumbnail cache payload +#include "core/capture/capture_paths.h" // capture::resolveBankFile / normalizeSlashes +#include "core/capture/render_settings.h" // capture::CaptureActionDef / captureActionTable +#include "core/capture/tail_control.h" // capture::TailSetting — the tail toggle state +#include "core/model/bank_book.h" // BankBook / Bank / SlotMap (flat reasampler until its split wave) +#include "core/model/bank_model.h" // model::BankModel / model::Sample +#include "core/ui/action_bar.h" // ui::ActionBarRect / slots / clusters +#include "core/ui/bank_grid.h" // ui::GridSpec / Selection / ThumbnailKey / CellRect +#include "core/ui/card_drag.h" // ui::CardGesture / SlotCellRect / gesture decisions +#include "core/ui/card_meta.h" // ui::MusicalLength / formatters +#include "core/ui/component_geometry.h" // ui::KitBox / KitButtonBox / waveformColumnCount +#include "core/ui/drag_out.h" // ui::DragState / PanelClientRect / decideGesture +#include "core/ui/footer_bar.h" // ui::FooterBarLayout / computeFooterBar +#include "core/ui/mode_enable.h" // ui::tagButtonEnabled / TagTarget +#include "core/ui/overflow_menu.h" // ui::MenuButtonSpec / computeMenuButton +#include "core/ui/prune_button.h" // ui::ButtonRect / computePruneButton +#include "core/ui/tab_strip.h" // ui::TabStripSpec / layout / hit-test +#include "core/ui/theme.h" // ui::Role / InteractionState / KitColor +#include "core/ui/tooltip.h" // ui::TooltipBox / computeTooltip / stripActionPrefix +#include "core/version/app_version.h" // version::channelCommandId / appVersion / dock identity +#include "core/view/guid_diff.h" // view::GuidBaseline — new-content detection +#include "core/view/lane_keys.h" // view::isOnManualLane +#include "core/view/mode_switch.h" // view::SegmentRect / computeSegmentRects +#include "core/wire/instrument_drop.h" // wire::buildInstrumentDropPreset (S17) + +#include "shell/panel/panel_layout.h" // BankPanelFullHeight — the split-state enum + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::panel { + +// --- Real-namespace-home using-declarations ----------------------------------- +// +// The panel's pre-split internals reference the pure modules' symbols unqualified; +// these explicit per-symbol usings (NOT the core/namespaces.h shim) keep those +// references valid while documenting each symbol's Q-W1 home. Flat-`reasampler` +// symbols (BankBook / ViewModeModel / the draw_kit shell / persistBankOp / ...) +// resolve via the enclosing namespace and need no using. + +// core/ui +using ui::ActionBarRect; +using ui::ActionBarSlot; +using ui::ActionBarSpec; +using ui::ActionCluster; +using ui::ButtonRect; +using ui::CardGesture; +using ui::CellRect; +using ui::ClusterSpec; +using ui::CursorCue; +using ui::DragGesture; +using ui::DragModifiers; +using ui::DragState; +using ui::DropRegion; +using ui::FooterBarLayout; +using ui::FooterBarSpec; +using ui::FooterHit; +using ui::FooterRect; +using ui::GridSpec; +using ui::InteractionState; +using ui::KitBox; +using ui::KitButtonBox; +using ui::KitColor; +using ui::MenuBarRect; +using ui::MenuButtonRect; +using ui::MenuButtonSpec; +using ui::MusicalLength; +using ui::NavKey; +using ui::PanelClientRect; +using ui::PruneButtonSpec; +using ui::ResolvedSample; +using ui::Role; +using ui::Selection; +using ui::SlotCellRect; +using ui::TabHit; +using ui::TabHitKind; +using ui::TabRect; +using ui::TabStripLayout; +using ui::TabStripRect; +using ui::TabStripSpec; +using ui::TagTarget; +using ui::ThumbnailKey; +using ui::TooltipBox; +using ui::TooltipSpec; +using ui::applyClick; +using ui::assemblePathList; +using ui::clampTabScroll; +using ui::columnsForWidth; +using ui::computeBarSlots; +using ui::computeFooterBar; +using ui::computeMenuButton; +using ui::computePruneButton; +using ui::computeSlotRects; +using ui::computeSlotRectsForDrop; +using ui::computeTabRects; +using ui::computeTabStripLayout; +using ui::computeTooltip; +using ui::cursorForGesture; +using ui::decideCardGesture; +using ui::decideGesture; +using ui::formatBarsBeats; +using ui::formatSecondsMs; +using ui::hitTestActionBar; +using ui::hitTestFooterBar; +using ui::hitTestMenuButton; +using ui::hitTestPruneButton; +using ui::hitTestSlot; +using ui::hitTestTabStrip; +using ui::menuButtonReserve; +using ui::navigate; +using ui::roleColor; +using ui::stripActionPrefix; +using ui::tagButtonEnabled; +using ui::thumbnailKeyString; +using ui::waveformColumnCount; + +// core/view +using view::GuidBaseline; +using view::HeaderRect; +using view::SegmentRect; +using view::computeSegmentRects; +using view::hitTestSegment; +using view::isOnManualLane; + +// core/capture +using capture::CaptureActionDef; +using capture::TailMode; +using capture::TailSetting; +using capture::adjustManualMs; +using capture::captureActionTable; +using capture::clampManualMs; +using capture::cycleTailMode; +using capture::kManualStepMs; +using capture::normalizeSlashes; +using capture::resolveBankFile; +using capture::tailToggleLabel; + +// core/audio +using audio::Envelope; +using audio::computeEnvelope; + +// core/model +using model::BankModel; +using model::Sample; + +// core/version +using version::actionDisplayPrefix; +using version::appVersion; +using version::channelCommandId; +using version::dockIdent; +using version::dockTitle; + +// core/wire +using wire::buildInstrumentDropPreset; + +// --- Layout constants --------------------------------------------------------- +// +// L2: every panel COLOR comes from the pure `theme` module by ROLE (drawn through the +// L1 kit — fillSurface / drawButton / kit text). Only the pixel LAYOUT metrics (band +// heights, grid/tab specs, insets) live here, shared by the layout/render/input/drag +// seams so draw and hit-test can never drift. + +inline const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; + +// --- Footer (Phase L, L4) ----------------------------------------------------- +// The footer carries a task-cluster of small persistent controls: the narrowed +// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 — +// a real kit button, no longer a click-zone), and the set-apart Prune button at the +// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly. +// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height. +inline constexpr int kFooterHeight = 30; + +// --- Toolbars (Phase L, L4) --------------------------------------------------- +// TWO task-grouped toolbars, both drawn through the pure action_bar module: +// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of +// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there. +// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above +// the footer (L4 §2). This is the L2 action-bar band, repurposed. +inline constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) +inline constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent + +// --- Tooltip (Phase L, L5) ---------------------------------------------------- +// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the +// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the +// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font +// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is +// generous — a slight over/under-estimate only pads the box, never clips the text). +inline constexpr unsigned int kTooltipDelayMs = 500; +inline constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) +inline constexpr int kTooltipTextH = 14; // approx line height at Font::Label + +// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- +// +// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | +// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer +// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool +// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER +// band: a title, the active-bank readout, and a full-height toggle button. The named-banks +// region's header ALSO hosts the LICE tab strip and a "+" create button. +inline constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band +inline constexpr int kTabStripHeight = 26; // the named-banks tab strip band +inline constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions +inline constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button +inline constexpr int kCreateBtnWidth = 22; // the "+" create-bank button + +// Tab strip metrics (the pure tab_strip owns the math; these are its inputs). +inline const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; + +// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its +// geometry + the reserve the action_bar leaves for it (L5). +inline const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, + /*verticalInset=*/3, /*minLeftInset=*/40}; + +// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared +// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: +// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6: +// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now. +inline const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, + /*sidePad=*/8, /*verticalInset=*/3}; + +// --- Panel state -------------------------------------------------------------- + +struct CachedThumbnail { + Envelope envelope; + int width = 0; +}; + +// Which of the two split regions currently owns the selection / receives keyboard +// input. The move/copy source is the focused region's displayed bank. +enum class Region { Pool, Banks }; + +// What a drag is dropping onto, resolved live under the pointer during a drag. +// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT +// on a specific tab (tab takes precedence — more specific wins). The resolved bank is +// always shownBankId. +enum class DropKind { None, PoolRegion, Tab, BanksRegion }; + +// --- Hover model (Phase L, L2) ------------------------------------------------ +// +// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its +// hover state on that element only (the "hover on every interactive element" + "sub-frame +// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep +// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None +// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. +// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. +enum class HoverKind { + None, + TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows) + BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows) + MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) + PruneButton, + FullHtPool, // pool region full-height toggle + FullHtBanks, // banks region full-height toggle + CreateBank, // the "+" create-bank button + Tab, // a named-bank tab (index = tab ordinal) + TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) + ModeSegment, // a footer mode-toggle segment (index = segment ordinal) +}; + +struct Hover { + HoverKind kind = HoverKind::None; + int index = -1; + + bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; } + bool operator!=(const Hover& o) const { return !(*this == o); } +}; + +// The kit interaction state for an interactive element: Hover when this (kind,index) is the +// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. +// an active tab draws Active regardless of hover); this is the base rest/hover resolver. +inline InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { + return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover + : InteractionState::Rest; +} + +struct PanelState { + ReaSamplerSession* session = nullptr; + + HWND hwnd = nullptr; + bool open = false; + + std::string bankFingerprint; + std::uint64_t generation = 0; + + std::unordered_map cache; + + // --- Selection (per focused region) --------------------------------------- + // One live selection, scoped to `focusedRegion`. Switching regions moves the + // selection with the focus (a click in the other region reseeds it there). + Selection selection; + int selItemCount = 0; + Region focusedRegion = Region::Pool; + + // --- Hover (Phase L, L2) -------------------------------------------------- + // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its + // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). + Hover hovered; + + // --- Tooltip (Phase L, L5) ------------------------------------------------ + // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a + // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel + // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the + // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover + // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly + // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the + // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. + unsigned int hoverSinceTick = 0; + bool tooltipShown = false; + + // --- Vertical-split state ------------------------------------------------- + BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; + + // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT + // from the active/capture-target bank (book().activeBankId()). Empty when there + // are no named banks. Reconciled each fingerprint pass so it always names a live + // named bank (or is empty). + std::string shownBankId; + + // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. + int tabScroll = 0; + + // --- Drag (sample move between regions/onto a tab) ------------------------ + // A drag begins only after the pointer moves past a threshold from a press that + // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 + // multi-select drag (which begins immediately on any grid press). See handleClick/ + // onMouseMove. dragging is true once the threshold is crossed. + bool dragArmed = false; // pressed on a selected cell; watching for threshold + bool dragging = false; // threshold crossed; a move-drag is in progress + int dragStartX = 0, dragStartY = 0; + Region dragSourceRegion = Region::Pool; + std::string dragSourceBankId; // the bank the dragged samples come from + std::vector dragSampleIds;// snapshot of the selection at drag start + std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 + // reorder/replace subject (see onLBtnUp dispatch) + DropKind dropKind = DropKind::None; // live drop target under the pointer + std::string dropBankId; // destination bank id when dropKind==Tab + + // --- L7 in-grid reorder/replace drag -------------------------------------- + // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- + // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the + // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 + // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue + // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. + CardGesture cardGesture = CardGesture::None; + int dragTargetSlot = -1; + + // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- + // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an + // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX + // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the + // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last + // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on + // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. + MediaTrack* instrumentDropTrack = nullptr; + + // --- Tail-mode toggle ----------------------------------------------------- + // The authoritative tail setting lives in ReaSamplerSession (session->tail()), + // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, + // restores it on project load). The panel reads it for drawing and mutates it via + // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the + // project dirty so the choice saves. bankPanelTailSetting is the read seam for the + // capture actions. Held here only through the session pointer above. + + // --- Audition preview ----------------------------------------------------- + preview_register_t preview{}; + PCM_source* previewSrc = nullptr; + bool previewActive = false; + bool previewInited = false; // guards double init / deinit + + // --- New-content detection (D2 Wave 2) ------------------------------------ + // + // Each timer tick diffs the live track+item GUID set against the previous tick to + // auto-tag content created SINCE the last tick into the then-active mode. The + // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its + // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). + // + // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a + // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact + // tick persist restores a project's membership + active mode (the same tick it + // reapplies the active mode); that sets reloadPending so the NEXT detect tick this + // same tick re-baselines against the fully-loaded set and reports nothing new. This + // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than + // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto + // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then + // diffed against the previous project's stale baseline and were mass-tagged into the + // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the + // two identity checks agree by construction. + // + // Lives for the extension's lifetime alongside the session, independent of panel + // open/close — detection must run whether or not the dock is visible (content is + // created in the arrange, not the panel). + GuidBaseline contentBaseline; + bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick +}; + +// The one shared panel state blob. Defined in panel_window.cpp (the lifecycle owner). +extern PanelState g_panel; + +// --- L7 slot-order display bridge --------------------------------------------- +// +// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted +// SlotMap order (sparse, gap-preserving), NOT BankModel insertion order. regionDisplay +// (panel_layout.cpp) is the single place that resolves a region's display, composed +// purely from bank_book's slot order (orderedSampleIds) + card_drag's sparse slot rects +// (computeSlotRects) — the shell adds no layout math of its own. +// +// TWO INDEX SPACES the whole panel must keep straight: +// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty +// cells and are valid drop targets. This is what pixels/hit-tests speak. +// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / +// applyClick / navigate reason in. Selection index i <-> orderedIds[i]. +// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS +// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). +// RegionDisplay carries both plus the translation between them, resolved FRESH each call +// (never cached across a mutation, per the reference-invalidation guardrail). +struct RegionDisplay { + std::vector orderedIds; // occupied ids in slot order (selection space) + std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords + const Bank* bank = nullptr; + + // The id occupying `slot`, or "" for an empty slot / out of range. + std::string idAtSlot(int slot) const { + return bank ? bank->slots.idAt(slot) : std::string{}; + } + // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot. + int slotForSelection(int sel) const { + if (sel < 0 || sel >= static_cast(orderedIds.size()) || !bank) return -1; + return bank->slots.slotOf(orderedIds[static_cast(sel)]); + } + // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when + // the slot is empty. Inverse of slotForSelection. + int selectionForSlot(int slot) const { + const std::string id = idAtSlot(slot); + if (id.empty()) return -1; + for (std::size_t i = 0; i < orderedIds.size(); ++i) + if (orderedIds[i] == id) return static_cast(i); + return -1; + } + int occupiedCount() const { return static_cast(orderedIds.size()); } +}; + +// --- Toolbar row vocabulary (Phase L, L4/L5/L6) -------------------------------- +// +// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix +// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action +// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is +// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS +// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster +// in its toolbar's cluster order. Built by panel_layout (topBarRows / bottomBarRows / +// overflowMenuRows); consumed by the render draw, the input click routing, and the drag hover. +struct ActionBarRow { + std::string suffix; + std::string shortLabel; + std::string fullName; + ActionCluster cluster = ActionCluster::Capture; + bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true + // for the top bar (its actions are unconditional triggers). +}; + +// --- Shared one-liner helpers -------------------------------------------------- + +// Modifier state at event time. Alt = the L7 replace modifier. +inline bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } +inline bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } +inline bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } + +inline void invalidatePanel() { + if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); +} + +// --- Cross-seam contract (grouped by OWNING TU; all plain free functions) ------ + +// panel_bank_ops.cpp — book/bank accessors + the bank-CRUD verbs + menus. +BankBook* book(); +const BankModel* indexForRegion(Region r); +std::string bankIdForRegion(Region r); +std::vector namedBanks(); +std::string currentProjectDir(); +void doCreateBank(); +void transferSamples(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); +void removeSamples(const std::vector& sampleIds, + const std::string& srcBankId); +std::vector focusedSelectionIds(); +std::vector resolveDragPathsForOs(); +void showTabMenu(int screenX, int screenY, const std::string& bankId); +void showSelectionMenu(int screenX, int screenY); +void showMoreMenu(); + +// panel_layout.cpp — toolbar/footer/menu rects, row/cluster builders, split geometry, +// region rects, the L7 display bridge. Draw and hit-test both call these so they never drift. +int modeCount(); +MenuButtonRect topMenuButtonRect(int w); +ActionBarRect topToolbarActionRect(int w); +RECT panelFooter(int w, int h); +FooterBarLayout footerBarLayoutFor(int w, int h); +ButtonRect pruneButtonRectFor(int w, int h); +bool pointInFooter(int x, int y); +ActionBarRect bottomToolbarRect(int w, int h); +RECT splitBody(int w, int h); +bool poolShown(); +bool banksShown(); +RECT poolRegionRect(int w, int h); +RECT banksRegionRect(int w, int h); +RECT regionHeaderRect(const RECT& region); +TabStripRect banksTabStripRect(const RECT& region); +RECT regionGridRect(const RECT& region, bool isBanks); +RECT fullHtBtnRect(const RECT& region); +RECT createBtnRect(const RECT& region); +RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg); +RegionDisplay focusedDisplay(); +bool regionAt(int x, int y, Region& out); +int footerToggleSegmentHit(int x, int y, int w, int h); +int columnsForRegion(Region reg); +std::vector topBarRows(); +std::vector bottomBarRows(); +std::vector overflowMenuRows(); +std::vector actionBarClusters(const std::vector& rows); +int resolveBarCommandId(const ActionBarRow& row); +int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows); +bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah); + +// panel_render.cpp — the full LICE paint (drawn last-to-front into the caller's +// double buffer; BitBlt'd once by paintPanel). +void paintPanel(HWND hwnd, HDC hdc); + +// panel_thumbnails.cpp — thumbnail compute + cache, and the bank-change fingerprint +// pass that owns the cache's generation key (bumps generation, clears the cache, and +// reconciles selection/shown-bank on any book mutation). +const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir); +bool refreshFingerprint(); +void reconcileShownBank(); + +// panel_audition.cpp — the preview engine (HOT PATH: direct call-through, never +// virtual, no added header->TU indirection — T4-28 / Q-W2 guardrail). +void initPreview(); +void deinitPreview(); +void stopAudition(); +void startAudition(int idx); + +// panel_input.cpp — click/wheel/key routing, accelerator, new-content detection, +// and the session-tail read/mutate helpers. +TailSetting currentTail(); +void handleClick(int x, int y); +bool handleWheel(int x, int y, int delta); +void registerAccel(); +void unregisterAccel(); + +// panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag). +// Per-mouse-move work stays plain free-function calls (T4-28). +void onMouseMove(int x, int y); +void onLBtnUp(int x, int y); +void handleRightClick(int x, int y); +void resetDragState(); +void maybeShowTooltip(); + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_thumbnails.cpp b/src/shell/panel/panel_thumbnails.cpp new file mode 100644 index 0000000..5869004 --- /dev/null +++ b/src/shell/panel/panel_thumbnails.cpp @@ -0,0 +1,155 @@ +// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; M5/FA3). Owns the per-sample PCM read via PCM_source +// fed to peaks::computeEnvelope (one bin per drawn pixel column) and the in-memory +// thumbnail cache keyed by (sample id, bin width, bank generation) — plus the +// bank-change fingerprint pass that OWNS that generation key: refreshFingerprint bumps +// the generation, clears the cache, resets selection/audition, and reconciles the +// shown bank on any book mutation. (The fingerprint pass lives here rather than in +// panel_input because the cache + generation it invalidates are this seam's state — +// a Q-W2 placement judgment; the T4-01 audit lumped it under the input seam's range.) +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include +#include + +#include "shell/panel/panel_state.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) + +// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- + +Envelope computeThumbnail(const std::string& absPath, int width) { + if (width <= 0 || absPath.empty()) return {}; + + PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); + if (!src) return {}; + + const int nch = src->GetNumChannels(); + const double srate = src->GetSampleRate(); + const double lengthSec = src->GetLength(); + if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { + PCM_Source_Destroy(src); + return {}; + } + + std::int64_t totalFrames = static_cast(lengthSec * srate); + if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } + int frames = totalFrames > kMaxThumbnailFrames + ? kMaxThumbnailFrames + : static_cast(totalFrames); + + std::vector buf(static_cast(frames) * nch, 0.0); + PCM_source_transfer_t block{}; + block.time_s = 0.0; + block.samplerate = srate; + block.nch = nch; + block.length = frames; + block.samples = buf.data(); + block.samples_out = 0; + src->GetSamples(&block); + + PCM_Source_Destroy(src); + + const int got = block.samples_out; + if (got <= 0) return {}; + + const std::size_t sampleCount = static_cast(got) * nch; + std::vector pcm(sampleCount); + for (std::size_t i = 0; i < sampleCount; ++i) + pcm[i] = static_cast(buf[i]); + + // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with + // trailing empty {0,0} bins, which would render a very short sample as a comb of + // spikes over flat gaps. + const int binCount = width < got ? width : got; + return computeEnvelope(pcm, static_cast(nch), + static_cast(got), + static_cast(binCount)); +} + +const Envelope& thumbnailFor(const Sample& sample, int width, + const std::string& projectDir) { + ThumbnailKey key{sample.id, width, g_panel.generation}; + const std::string ks = thumbnailKeyString(key); + + auto it = g_panel.cache.find(ks); + if (it != g_panel.cache.end()) return it->second.envelope; + + const std::string abs = resolveBankFile(projectDir, sample.relativePath); + CachedThumbnail thumb; + thumb.width = width; + thumb.envelope = computeThumbnail(abs, width); + auto ins = g_panel.cache.emplace(ks, std::move(thumb)); + return ins.first->second.envelope; +} + +// --- Bank-change detection ---------------------------------------------------- + +// A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag +// + per-sample id/path. Catches every mutation the panel must redraw for: capture, +// project load, and B4's own create/rename/delete/move/activate. +std::string bookFingerprint() { + BankBook* b = book(); + if (!b) return {}; + std::string fp = std::to_string(b->size()); + fp += '\x1e'; fp += b->activeBankId(); + for (const Bank& bk : b->banks()) { + fp += '\x1d'; + fp += bk.id; + fp += '\x1c'; + fp += bk.displayName; + for (const Sample& s : bk.index.all()) { + fp += '\x1f'; + fp += s.id; + fp += '\x1f'; + fp += s.relativePath; + } + } + return fp; +} + +// Reconciles shownBankId against the live named banks: keep it if it still names a +// named bank; otherwise fall to the first named bank (or empty when none). Keeps the +// banks region always showing a valid tab. Never touches the ACTIVE bank. +void reconcileShownBank() { + BankBook* b = book(); + if (!b) { g_panel.shownBankId.clear(); return; } + if (!g_panel.shownBankId.empty()) { + const Bank* bk = b->bank(g_panel.shownBankId); + if (bk && !bk->isPool()) return; // still valid + } + const std::vector named = namedBanks(); + g_panel.shownBankId = named.empty() ? std::string() : named.front()->id; +} + +bool refreshFingerprint() { + if (!book()) return false; + std::string fp = bookFingerprint(); + if (fp == g_panel.bankFingerprint) return false; + g_panel.bankFingerprint = std::move(fp); + ++g_panel.generation; + g_panel.cache.clear(); + // The selection indexes into the OLD order; a change can invalidate those, so + // clear it and stop any audition. + if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { + g_panel.selection = Selection{}; + stopAudition(); + } + reconcileShownBank(); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); + g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; + return true; +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp new file mode 100644 index 0000000..5cf087b --- /dev/null +++ b/src/shell/panel/panel_window.cpp @@ -0,0 +1,243 @@ +// panel_window.cpp — the window-lifecycle seam of the docked bank panel (Q-W2 split +// of bank_panel.cpp; M5 Wave A). Owns the SWELL dialog (IDD_BANK_PANEL) docked via +// DockWindowAddEx / undocked via DockWindowRemove, the dialog proc that routes +// messages to the input/drag/render/audition seams, the S8 OS drop-target opt-in +// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition. +// +// READ-ONLY of the TIMELINE (load-bearing principle): the panel never inserts into +// the arrange. Channel-qualified dock identity (Phase V, V4): title + persisted- +// position identstr both come from app_version. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_window.h" + +#include "shell/panel/draw_kit.h" // kitFontsInit/Shutdown — the kit's cached AA fonts (L1) +#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest +#endif + +#include "resource.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_DockWindowAddEx +#define REAPERAPI_WANT_DockWindowActivate +#define REAPERAPI_WANT_DockWindowRemove +#define REAPERAPI_WANT_GetMainHwnd +#include "reaper_plugin_functions.h" + +// main.cpp owns the module instance handle. +extern REAPER_PLUGIN_HINSTANCE g_hInst; + +namespace reasampler::panel { + +// The one shared panel state blob (declared extern in panel_state.h). Defined here — +// the lifecycle seam owns the state's lifetime, mirroring the old single-TU global. +PanelState g_panel; + +// --- Dialog proc + docking ---------------------------------------------------- + +// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands +// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active +// bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP +// (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) +// returns the file count; then each path is queried by index. Both Win32 and SWELL expose +// DragQueryFile/DragFinish with this contract. +void handleDropFiles(HDROP hDrop) { + std::vector paths; + const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); + paths.reserve(count); + for (UINT i = 0; i < count; ++i) { + // Query the required length first (excludes the NUL), then read into a sized buffer. + const UINT len = DragQueryFile(hDrop, i, nullptr, 0); + if (len == 0) continue; + std::vector buf(static_cast(len) + 1, '\0'); + DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); + std::string p(buf.data()); + if (!p.empty()) paths.push_back(std::move(p)); + } + DragFinish(hDrop); + if (!paths.empty()) ingestDroppedFiles(paths); +} + +WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_DROPFILES: + // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import + // into the active bank (bank-fill only). wParam is the HDROP. + handleDropFiles(reinterpret_cast(wParam)); + return 0; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + paintPanel(hwnd, hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: { + SetFocus(hwnd); + handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + } + case WM_MOUSEMOVE: + onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_LBUTTONUP: + onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_RBUTTONDOWN: + SetFocus(hwnd); + handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_CAPTURECHANGED: + // Capture lost (pointer left window pre-threshold and released outside, or another + // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale + // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is + // mutated on a cancel; the cursor is restored to the arrow. + if (g_panel.dragArmed || g_panel.dragging) { + resetDragState(); + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + invalidatePanel(); + } + return 0; + case WM_MOUSEWHEEL: { + // Fine-adjust the Manual tail length when the wheel is over the footer. + // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in + // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so + // convert to client space before hit-testing the footer. The signed wheel + // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, + // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer + // handler acts, so scrolling elsewhere in the dock still behaves normally. + POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + ScreenToClient(hwnd, &pt); + const int delta = static_cast(HIWORD(wParam)); + return handleWheel(pt.x, pt.y, delta) ? 1 : 0; + } + case WM_DESTROY: + if (GetCapture() == hwnd) ReleaseCapture(); + stopAudition(); + g_panel.selection = Selection{}; + resetDragState(); + g_panel.hovered = Hover{}; + g_panel.tooltipShown = false; + g_panel.hwnd = nullptr; + g_panel.open = false; + return 0; + default: + break; + } + return 0; +} + +void openPanel() { + if (g_panel.open && g_panel.hwnd) { + DockWindowActivate(g_panel.hwnd); + return; + } + initPreview(); + + // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so + // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts + // are torn down once at bankPanelShutdown. All panel text draws through these. + kitFontsInit(); + + g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), + GetMainHwnd(), dlgProc, 0); + if (!g_panel.hwnd) return; + + // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position + // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler + // Bank beta") and does not fight over stable's saved dock slot (the identstr is a + // REAPER-global collision surface — it keys the persisted dock position). + DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); + DockWindowActivate(g_panel.hwnd); + g_panel.open = true; + + // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). + // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, + // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 + // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration + // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses + // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means + // would still ingest — only the accept opt-in is gated. +#ifdef _WIN32 + DragAcceptFiles(g_panel.hwnd, TRUE); +#endif + + registerAccel(); + + reconcileShownBank(); + refreshFingerprint(); +} + +void closePanel() { + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + stopAudition(); + g_panel.selection = Selection{}; + resetDragState(); + unregisterAccel(); + if (g_panel.hwnd) { + DockWindowRemove(g_panel.hwnd); + DestroyWindow(g_panel.hwnd); + g_panel.hwnd = nullptr; + } + g_panel.open = false; +} + +} // namespace reasampler::panel + +// --- Public API (the lifecycle seam — panel_window.h) -------------------------- + +namespace reasampler { + +void bankPanelInit(ReaSamplerSession* session) { + panel::g_panel.session = session; +} + +// Returns true only when the panel window is actually visible to the user right now. +// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the +// HWND and g_panel.open are still live — the live query is the source of truth for +// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp). +static bool panelEffectivelyVisible() { + return panel::g_panel.hwnd && IsWindowVisible(panel::g_panel.hwnd); +} + +void bankPanelToggle() { + // Decide from live visibility, not the cached g_panel.open flag. + // Alt+D hides the docker without destroying the window, leaving g_panel.open + // stale (true) while the panel is gone. Using IsWindowVisible avoids the + // double-fire needed to re-show the panel after a docker hide. + if (panelEffectivelyVisible()) + panel::closePanel(); + else + panel::openPanel(); +} + +bool bankPanelIsOpen() { + // Derive from live window state so the Actions-list checkmark stays honest + // even after Alt+D hides the docker without notifying the extension. + return panelEffectivelyVisible(); +} + +void bankPanelInvalidate() { + if (panel::g_panel.hwnd) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelShutdown() { + panel::closePanel(); + panel::deinitPreview(); + kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) + panel::g_panel.cache.clear(); + panel::g_panel.session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_window.h b/src/shell/panel/panel_window.h new file mode 100644 index 0000000..654727c --- /dev/null +++ b/src/shell/panel/panel_window.h @@ -0,0 +1,42 @@ +#pragma once +// panel_window — the window-lifecycle seam of the docked bank panel (Q-W2 split of +// bank_panel.h; M5, Wave A). REAPER-facing shell: the .cpp owns a SWELL dialog +// (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via DockWindowRemove, +// toggled open/closed, plus the OS drop-target opt-in (S8) and the dialog proc that +// routes messages to the input/drag/render seams. The panel itself NEVER inserts +// into the arrange or mutates the project (CONTEXT.md §load-bearing principle). +// +// The header is REAPER-free: main.cpp drives the panel through these free functions, +// passing the live session so the panel reads the current bank. All SWELL / LICE / +// PCM_source use is confined to the shell/panel/ .cpp seams. + +namespace reasampler { + +class ReaSamplerSession; + +// Wires the panel into main.cpp's lifecycle. Called once after the API pointers +// are loaded, BEFORE the toggle action is registered. `session` must outlive the +// panel (it is the extension-lifetime g_session). Stores the session pointer the +// panel reads on every repaint; does not create the window yet. +void bankPanelInit(ReaSamplerSession* session); + +// Toggles the docked window: creates+docks it if hidden, hides+undocks it if +// shown. Bound to the "toggle bank panel" action. Safe to call before the first +// timer tick. +void bankPanelToggle(); + +// Whether the panel window is currently open/visible. Feeds the action's +// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. +bool bankPanelIsOpen(); + +// Requests an immediate repaint of the panel if it is open. A no-op when the panel +// is closed (safe to call unconditionally). Called by the actions layer after a +// mode change so the footer [Arrange|Design] toggle reflects the new mode without +// requiring a hide/reshow. +void bankPanelInvalidate(); + +// Tears the panel down on extension unload: destroys the window and releases any +// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. +void bankPanelShutdown(); + +} // namespace reasampler From 19b12186ac5778e8768452dc009418d9ded13230 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:28:48 -0400 Subject: [PATCH 26/40] Q-W2 review follow-ups: anon-namespace TU-private panel helpers, fix stale bank_panel.cpp comments, correct shim-transitivity claim Wraps ~50 file-local helpers across all eight panel TUs in namespace{} (dissolves the menuAppend default-arg ODR trap); zero behavior change, 60/60 green. --- src/shell/panel/panel_bank_ops.cpp | 12 ++++++++++++ src/shell/panel/panel_drag.cpp | 8 ++++++++ src/shell/panel/panel_input.cpp | 12 ++++++++++-- src/shell/panel/panel_layout.cpp | 12 ++++++++++++ src/shell/panel/panel_render.cpp | 4 ++++ src/shell/panel/panel_state.h | 10 ++++++---- src/shell/panel/panel_thumbnails.cpp | 8 ++++++++ src/shell/panel/panel_window.cpp | 4 ++++ 8 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index b3f630d..621eb04 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -87,6 +87,8 @@ std::vector namedBanks() { // unsaved project the empty-close discard in persistBankOp ensures no stale state // survives (matches the capture/B3 quiet-persist idiom). +namespace { + // REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). bool promptText(const char* title, const char* caption, const std::string& initial, std::string& out) { @@ -111,6 +113,8 @@ std::string mintBankId() { return std::string(buf); } +} // namespace + void doCreateBank() { if (!book()) return; std::string name; @@ -127,6 +131,8 @@ void doCreateBank() { invalidatePanel(); } +namespace { + void doRenameBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); @@ -198,6 +204,8 @@ void doActivateBank(const std::string& bankId) { invalidatePanel(); } +} // namespace + // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass // ids straight to the model op (no BankModel& cached across the loop's mutations). // @@ -312,6 +320,8 @@ std::vector resolveDragPathsForOs() { // Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD // hands the chosen id straight back, so no hookcommand routing is involved. +namespace { + // Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both // accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative // position appending. Win32 and SWELL both treat pos < 0 as an append. @@ -337,6 +347,8 @@ enum : unsigned int { kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index }; +} // namespace + // Shows the right-click context menu for a named-bank TAB: activate / rename / delete // / evacuate that bank, plus a create entry. Drives the id-keyed ops. void showTabMenu(int screenX, int screenY, const std::string& bankId) { diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 094e37d..68fb38e 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -30,6 +30,8 @@ namespace reasampler::panel { constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag +namespace { + // Resolves the drop target under client (x, y) during a drag, updating dropKind / // dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; // anywhere else -> none. @@ -234,6 +236,8 @@ void updateHover(int x, int y) { } } +} // namespace + // Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past // kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the // OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; @@ -365,6 +369,8 @@ void onMouseMove(int x, int y) { // batched undo point + saves). A no-op reorder (already at the target, model returns false) // opens no undo point. Selection reasons over slot order, so it is cleared after — the // fingerprint pass rebuilds it against the new order. +namespace { + void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point @@ -386,6 +392,8 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId, invalidatePanel(); } +} // namespace + // Clears all drag-state fields to their resting values. Called from every exit path // (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields // stays consistent across all four sites. diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 975e460..5163172 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -48,6 +48,8 @@ TailSetting currentTail() { return g_panel.session ? g_panel.session->tail() : TailSetting{}; } +namespace { + // Commits the current tail setting to ext state and marks the active project dirty // so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only // path that calls SetProjExtState for the tail key — calling it here closes the gap @@ -96,7 +98,7 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar, // True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified // in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so -// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace. +// panel_input.cpp stays self-contained without pulling in view.cpp's private namespace. constexpr int kFreeModeFixedLanes = 2; bool isFixedLaneTrack(MediaTrack* tr) { @@ -104,7 +106,7 @@ bool isFixedLaneTrack(MediaTrack* tr) { } // Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): -// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies. +// itemGuid(it) and itemLaneName(tr, it). panel_input.cpp no longer carries its own copies. // Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, // baseline input) and, for each item, records whether it sits on a manual lane so a @@ -319,6 +321,8 @@ bool handlePoolChromeClick(int x, int y, const RECT& region) { return false; } +} // namespace + // Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / // bottom toolbar / region chrome / grid selection, and arm a potential drag when the click // lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. @@ -500,6 +504,8 @@ bool handleWheel(int x, int y, int delta) { return true; } +namespace { + bool isOurWindow(HWND hwnd) { for (HWND w = hwnd; w; w = GetParent(w)) if (w == g_panel.hwnd) return true; @@ -557,6 +563,8 @@ int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { accelerator_register_t g_accel{translateAccel, true, nullptr}; bool g_accelRegistered = false; +} // namespace + void registerAccel() { if (g_accelRegistered || !g_rec) return; g_rec->Register("accelerator", &g_accel); diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index ca1f797..320c644 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -49,6 +49,8 @@ int modeCount() { // buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run // under the menu button (L5 refinement 1). +namespace { + ActionBarRect topToolbarRect(int w) { ActionBarRect s; s.x = 0; @@ -67,6 +69,8 @@ MenuBarRect topMenuBarRect(int w) { // The More button's rect (right-anchored in the top band). Empty when the band is too narrow // to place it clear of its left inset — the three variants stay reachable via their bindable // commands (graceful suppression). +} // namespace + MenuButtonRect topMenuButtonRect(int w) { return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); } @@ -200,6 +204,8 @@ std::vector overflowMenuRows() { }; } +namespace { + // The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for // "which mode is active"). Empty when no session (every button then falls to fail-open live). std::string activeModeIdOrEmpty() { @@ -207,6 +213,8 @@ std::string activeModeIdOrEmpty() { return g_panel.session->view().activeModeId(); } +} // namespace + // The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons // then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from // actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; @@ -290,6 +298,8 @@ int resolveBarCommandId(const ActionBarRow& row) { return NamedCommandLookup(named.c_str()); } +namespace { + // The current key binding string for a command in the MAIN section, or "" (unbound / not // registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). std::string barBindingText(int cmd) { @@ -300,6 +310,8 @@ std::string barBindingText(int cmd) { return {}; } +} // namespace + // The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { if (bar.height <= 0) return -1; diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 6f17d32..04e44d6 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -23,6 +23,8 @@ namespace reasampler::panel { +namespace { + // --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- // Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT @@ -506,6 +508,8 @@ std::string activeBankName() { return bk ? bk->displayName : std::string(kPoolBankName); } +} // namespace + // --- Full paint --------------------------------------------------------------- void paintPanel(HWND hwnd, HDC hdc) { diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index f0e265f..e328222 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -14,10 +14,12 @@ // plain free function — direct call-through, no interface, no virtual dispatch // (T4-28: the audition path and the per-mouse-move path must stay direct calls). // * Explicit using-declarations pulling the pure modules' symbols into -// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — the -// panel TUs do NOT include the interim core/namespaces.h shim (Q-W2 retires it -// for this module; some still-unsplit shell headers carry it transitively until -// their own waves, but nothing here depends on it). +// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — this +// header itself does not directly include the interim core/namespaces.h shim +// (Q-W2 retires that direct dependency for this module). Six of the eight panel +// TUs still pull the shim in TRANSITIVELY via actions.h/persist.h/ingest.h/ +// draw_kit.h/view.h; only panel_thumbnails.cpp and panel_audition.cpp are +// shim-free end to end. Nothing HERE depends on it either way. // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, diff --git a/src/shell/panel/panel_thumbnails.cpp b/src/shell/panel/panel_thumbnails.cpp index 5869004..65bc1f5 100644 --- a/src/shell/panel/panel_thumbnails.cpp +++ b/src/shell/panel/panel_thumbnails.cpp @@ -25,6 +25,8 @@ namespace reasampler::panel { +namespace { + constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) // --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- @@ -78,6 +80,8 @@ Envelope computeThumbnail(const std::string& absPath, int width) { static_cast(binCount)); } +} // namespace + const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir) { ThumbnailKey key{sample.id, width, g_panel.generation}; @@ -96,6 +100,8 @@ const Envelope& thumbnailFor(const Sample& sample, int width, // --- Bank-change detection ---------------------------------------------------- +namespace { + // A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag // + per-sample id/path. Catches every mutation the panel must redraw for: capture, // project load, and B4's own create/rename/delete/move/activate. @@ -119,6 +125,8 @@ std::string bookFingerprint() { return fp; } +} // namespace + // Reconciles shownBankId against the live named banks: keep it if it still names a // named bank; otherwise fall to the first named bank (or empty when none). Keeps the // banks region always showing a valid tab. Never touches the ACTIVE bank. diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp index 5cf087b..d64a9b4 100644 --- a/src/shell/panel/panel_window.cpp +++ b/src/shell/panel/panel_window.cpp @@ -46,6 +46,8 @@ PanelState g_panel; // --- Dialog proc + docking ---------------------------------------------------- +namespace { + // Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands // them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active // bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP @@ -193,6 +195,8 @@ void closePanel() { g_panel.open = false; } +} // namespace + } // namespace reasampler::panel // --- Public API (the lifecycle seam — panel_window.h) -------------------------- From ea86f540b86b72d0f9372b884f33cc8226d65471 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 10:56:09 -0400 Subject: [PATCH 27/40] =?UTF-8?q?Q-W2v:=20split=20VST=20god-modules=20?= =?UTF-8?q?=E2=80=94=20editor=208=20face-axis=20TUs=20(+pure=20layout=20ho?= =?UTF-8?q?ist),=20processor=203=20TUs,=20component=5Fstate=5Fio=20codec?= =?UTF-8?q?=20split=20(extension=20drops=20the=20voice=20engine),=20zone?= =?UTF-8?q?=5Fparams.h,=20core/wire=20putLE;=20formats=20frozen,=2061/61?= =?UTF-8?q?=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 93 +- src/core/instrument/engine/sampler_core.cpp | 10 + src/core/instrument/engine/sampler_core.h | 175 +- src/core/instrument/engine/zone_params.h | 192 + .../instrument/map/component_state_io.cpp | 508 +++ src/core/instrument/map/component_state_io.h | 326 ++ src/core/instrument/map/sample_map.cpp | 578 +-- src/core/instrument/map/sample_map.h | 310 +- src/core/instrument/ui/browser_scroll.cpp | 23 + src/core/instrument/ui/browser_scroll.h | 17 + src/core/instrument/ui/editor_geometry.cpp | 146 + src/core/instrument/ui/editor_geometry.h | 82 + src/core/wire/bytes.h | 110 + src/core/wire/instrument_drop.cpp | 32 +- src/shell/instrument/editor_controls.cpp | 397 +++ .../instrument/editor_input_browse_zone.cpp | 440 +++ src/shell/instrument/editor_input_sample.cpp | 586 ++++ src/shell/instrument/editor_internal.h | 239 ++ .../instrument/editor_paint_browse_zone.cpp | 278 ++ src/shell/instrument/editor_paint_sample.cpp | 518 +++ src/shell/instrument/editor_platform.cpp | 271 ++ src/shell/instrument/editor_session.cpp | 371 ++ src/shell/instrument/processor_reload.cpp | 489 +++ src/shell/instrument/processor_state.cpp | 311 ++ .../instrument}/reasampler_editor.h | 26 +- src/shell/instrument/reasampler_embed.cpp | 2 +- src/shell/instrument/reasampler_processor.cpp | 425 +++ .../instrument}/reasampler_processor.h | 11 +- src/shell/instrument/vst_entry.cpp | 2 +- src/vst/reasampler_editor.cpp | 3084 ----------------- src/vst/reasampler_processor.cpp | 1168 ------- tests/test_browser_scroll.cpp | 21 + tests/test_component_state_io.cpp | 179 + tests/test_editor_geometry.cpp | 75 + tests/test_instrument_drop.cpp | 3 +- tests/test_sample_map.cpp | 4 +- tests/test_wire.cpp | 52 + 37 files changed, 6202 insertions(+), 5352 deletions(-) create mode 100644 src/core/instrument/engine/zone_params.h create mode 100644 src/core/instrument/map/component_state_io.cpp create mode 100644 src/core/instrument/map/component_state_io.h create mode 100644 src/core/wire/bytes.h create mode 100644 src/shell/instrument/editor_controls.cpp create mode 100644 src/shell/instrument/editor_input_browse_zone.cpp create mode 100644 src/shell/instrument/editor_input_sample.cpp create mode 100644 src/shell/instrument/editor_internal.h create mode 100644 src/shell/instrument/editor_paint_browse_zone.cpp create mode 100644 src/shell/instrument/editor_paint_sample.cpp create mode 100644 src/shell/instrument/editor_platform.cpp create mode 100644 src/shell/instrument/editor_session.cpp create mode 100644 src/shell/instrument/processor_reload.cpp create mode 100644 src/shell/instrument/processor_state.cpp rename src/{vst => shell/instrument}/reasampler_editor.h (96%) create mode 100644 src/shell/instrument/reasampler_processor.cpp rename src/{vst => shell/instrument}/reasampler_processor.h (98%) delete mode 100644 src/vst/reasampler_editor.cpp delete mode 100644 src/vst/reasampler_processor.cpp create mode 100644 tests/test_component_state_io.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bf216f..6449117 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,9 +411,10 @@ target_include_directories(drag_out PUBLIC src) # TrackFX_SetPreset so a freshly-added ReaSampler 9000 plays that capture (the # former "vst_chunk" named-config-parm write fed REAPER raw component bytes its # VST3 wrapper framing cannot apply — the blank-on-drop regression). Reuses the -# instrument's OWN serializer (sample_map::serializeComponentState) — NOT a -# parallel byte writer — so the cross-artifact contract cannot drift; links -# sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and +# instrument's OWN serializer (component_state_io::serializeComponentState) — NOT +# a parallel byte writer — so the cross-artifact contract cannot drift; links +# component_state_io (Q-W2v codec split, T4-13 ≡ T2-07: the extension no longer +# links sampler_core/pitch_shift object code to serialize one preset blob) and # NEITHER SDK. The class-ID string derives from the FROZEN UID macros # (src/core/wire/reasampler_uid.h, SDK-free), channel-selected via the generated # version header — hence the generated include dir. The round-trip test parses @@ -422,7 +423,7 @@ target_include_directories(drag_out PUBLIC src) # --------------------------------------------------------------------------- add_library(instrument_drop STATIC src/core/wire/instrument_drop.cpp) target_include_directories(instrument_drop PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) -target_link_libraries(instrument_drop PUBLIC sample_map) +target_link_libraries(instrument_drop PUBLIC component_state_io) # --------------------------------------------------------------------------- # 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette @@ -547,10 +548,10 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with # (note, velocity) -> zone resolution, and repitch/interpolation from a root note # with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book, -# tested hard outside any host. Lives under src/vst/ (it is instrument code) but +# tested hard outside any host. Lives under core/instrument/engine/ but # links NEITHER SDK — the plain-data boundary is enforced structurally: the test # target below links only sampler_core (+ its peaks dep for the AudioSample alias, -# the one house precedent wav_trim also relies on). The VST3 shell (src/vst/ +# the one house precedent wav_trim also relies on). The VST3 shell (shell/instrument/ # reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. # --------------------------------------------------------------------------- # pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core). @@ -813,7 +814,7 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests) # (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge # read marshalling — GetProjExtState result decode + a small JSON string-field # reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; -# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified. +# the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified. # --------------------------------------------------------------------------- add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp) target_include_directories(editor_geometry PUBLIC src) @@ -830,19 +831,28 @@ add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp) target_include_directories(embed_strip PUBLIC src) target_link_libraries(embed_strip PUBLIC editor_geometry) -# sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank -# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser), -# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap -# build, and the selected-sample instance-state (de)serialization. Links the three pure -# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and -# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell -# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then -# calls these; the process callback stays allocation-free. +# sample_map (Phase S4; RESOLUTION half since Q-W2v) — PURE mapping logic for the +# instrument: the live bank blob -> selected sample (via the SHARED bank_book JSON parse, +# NOT a second parser), interleaved->mono downmix (the channel policy), the Tier-0/zoned +# keymap builds, and the refs/performance resolution. Links the three pure modules it +# composes — bank_book (shared JSON), wav_trim (shared WAV parse), and sampler_core (the +# Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor) +# does the bridge read + file I/O off the audio thread, then calls these; the process +# callback stays allocation-free. The ComponentState codec is component_state_io below. add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp) target_include_directories(sample_map PUBLIC src) -# master_gain: the v8 component-state master-gain field validates against the pure taper's -# linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor). -target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain) +target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) + +# component_state_io (Q-W2v split of sample_map, T4-13 ≡ T2-07) — the ComponentState +# ENVELOPE + zones-payload binary codec (envelope v1..v11, zones payload v1..v7, every +# lift preserved byte-identically). Split so the codec — which grows on every envelope +# bump and is shared with the EXTENSION's preset-blob path (instrument_drop) — links +# WITHOUT the voice engine: its deps are velocity_curve (the per-zone curve field) and +# master_gain (the v8 wire cap) only; sampler_core/pitch_shift object code never enters +# the extension binary. Its own test target linking exactly these is the structural proof. +add_library(component_state_io STATIC src/core/instrument/map/component_state_io.cpp) +target_include_directories(component_state_io PUBLIC src) +target_link_libraries(component_state_io PUBLIC velocity_curve master_gain) # capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the # capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly @@ -967,13 +977,21 @@ add_executable(embed_strip_tests tests/test_embed_strip.cpp) target_link_libraries(embed_strip_tests PRIVATE embed_strip) add_test(NAME embed_strip_tests COMMAND embed_strip_tests) -# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER -# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the -# sampler_core test enforces. +# sample_map: the S4 mapping heart. Links ONLY sample_map + component_state_io (+ their +# pure deps) — NEITHER the VST3 SDK nor the REAPER SDK — the same structural +# plain-data-boundary proof the sampler_core test enforces. (The historical suite spans +# both halves of the Q-W2v split; the frozen-format assertions live here unmodified.) add_executable(sample_map_tests tests/test_sample_map.cpp) -target_link_libraries(sample_map_tests PRIVATE sample_map) +target_link_libraries(sample_map_tests PRIVATE sample_map component_state_io) add_test(NAME sample_map_tests COMMAND sample_map_tests) +# component_state_io (Q-W2v): the ComponentState codec's OWN target. Links ONLY +# component_state_io (velocity_curve + master_gain transitively) — deliberately NO +# sampler_core/pitch_shift — the structural proof the codec is engine-free (T2-07). +add_executable(component_state_io_tests tests/test_component_state_io.cpp) +target_link_libraries(component_state_io_tests PRIVATE component_state_io) +add_test(NAME component_state_io_tests COMMAND component_state_io_tests) + add_executable(capture_browser_tests tests/test_capture_browser.cpp) target_link_libraries(capture_browser_tests PRIVATE capture_browser) add_test(NAME capture_browser_tests COMMAND capture_browser_tests) @@ -1209,8 +1227,22 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # --- 5b) The VST3 module (loadable .vst3 DLL). ------------------------------- add_library(reasampler_vst MODULE src/shell/instrument/vst_entry.cpp - src/vst/reasampler_processor.cpp - src/vst/reasampler_editor.cpp + # The processor family (Q-W2v, T4-12): lifecycle + process() whole (T4-29), with + # component-state I/O and the off-thread reload/publish family in sibling TUs. + src/shell/instrument/reasampler_processor.cpp + src/shell/instrument/processor_state.cpp + src/shell/instrument/processor_reload.cpp + # The editor family (Q-W2v, T4-11): eight face-axis TUs — session/bridge state, + # param plumbing, paint x2 (Sample | Browse+Zone), input x2 (same axis), platform; + # the eighth (editor_layout) hoisted PURE into core/instrument/ui/editor_geometry + # + browser_scroll (T2-06). Shared internals: editor_internal.h (no TU). + src/shell/instrument/editor_session.cpp + src/shell/instrument/editor_controls.cpp + src/shell/instrument/editor_paint_sample.cpp + src/shell/instrument/editor_paint_browse_zone.cpp + src/shell/instrument/editor_input_sample.cpp + src/shell/instrument/editor_input_browse_zone.cpp + src/shell/instrument/editor_platform.cpp src/shell/instrument/reasampler_embed.cpp src/shell/instrument/reaper_bridge.cpp # The Phase L (L1) draw kit — the ONE source of drawing the editor + embed shells @@ -1228,8 +1260,8 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # bank->keymap mapping + state (de)ser the processor drives off the audio thread; # linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model, # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / - # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst) - # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). + # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src) + # gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). # embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals # into; it links editor_geometry transitively (shared Rect). # app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so @@ -1264,16 +1296,15 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # sample_usage (pS-usage): the usage-record wire + publish plan the processor's # reloadInstrument publishes through the bridge (the one sanctioned VST-side write). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip app_version capture_browser keyboard_strip + sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck curve_popup master_gain sample_usage file_bytes) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. - # src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst: the - # two not-yet-split god TUs (reasampler_editor / reasampler_processor, Q-W2v) still - # live there and are included flat by the shell TUs. - target_include_directories(reasampler_vst PRIVATE src src/vst ${SDK_INC} ${WDL_INC}) + # src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst is GONE + # (Q-W2v): the two former god TUs live split under shell/instrument/. + target_include_directories(reasampler_vst PRIVATE src ${SDK_INC} ${WDL_INC}) # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk # product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to # pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set diff --git a/src/core/instrument/engine/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp index 0edf418..0982462 100644 --- a/src/core/instrument/engine/sampler_core.cpp +++ b/src/core/instrument/engine/sampler_core.cpp @@ -1,6 +1,16 @@ // sampler_core — pure sampler engine implementation. See sampler_core.h for the // contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, // voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. +// +// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v, +// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE. +// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called +// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from +// VoiceEngine::render — same-TU definition is what lets the compiler inline that +// stack (the build configures NO LTO). A by-class TU split would put the hottest +// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the +// phase forbids. Do NOT "fix" this file's length; the header is split instead +// (zone_params.h carries the shared value structs). #include "core/instrument/engine/sampler_core.h" diff --git a/src/core/instrument/engine/sampler_core.h b/src/core/instrument/engine/sampler_core.h index 354845c..5ef1e88 100644 --- a/src/core/instrument/engine/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -23,6 +23,7 @@ #include #include "core/audio/peaks.h" // AudioSample (float) +#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split) #include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) #include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) @@ -35,176 +36,10 @@ using instrument::engine::PitchShifter; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; -// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 -// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders -// per-channel. A PERFORMANCE choice the instrument owns (component state), never written -// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain -// value so the shell (bus negotiation, state) and the engine share one spelling; the core -// itself never branches on it — the mode only picks which render overload the shell drives. -enum class ChannelMode { Mono, Stereo }; - -// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's -// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE -// priority over a held-note stack (classic mono synth: a new note takes the voice over; the -// release of the top note falls back to the most-recent still-held note). A PERFORMANCE -// choice the instrument owns (component state), never a bank fact. Default Poly preserves -// current behavior. -enum class VoiceMode { Poly, Mono }; - -// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). -// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps -// the envelope running when a note is taken over while another is held — pitch moves without -// a re-attack (and the fallback on top-note release glides back the same way). Legato applies -// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts -// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample -// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. -enum class MonoTrigger { Retrigger, Legato }; - -// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. -// One spelling shared by the engine, the component-state (de)serializer, and the editor's -// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. -inline constexpr int kMinVoiceCount = 1; -inline constexpr int kMaxVoiceCount = 32; -inline constexpr int kDefaultVoiceCount = 16; - -// --------------------------------------------------------------------------- -// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because -// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching -// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower -// with the rest of the engine machinery; only the value structs need to precede SampleData. -// --------------------------------------------------------------------------- - -// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack -// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. -struct AdsrParams { - std::int64_t attackFrames = 0; - std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR - std::int64_t decayFrames = 0; - double sustainLevel = 1.0; // 0..1 - std::int64_t releaseFrames = 0; -}; - -// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's -// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, -// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone -// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. -enum class PlayMode { Gate, Trigger }; - -// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span -// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), -// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over -// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play -// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. -struct TriggerParams { - double lengthFraction = 1.0; // (0,1] of the post-start span to play - std::int64_t fadeInFrames = 0; // 0->1 ramp at the head - std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd -}; - -// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default -// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) -// so a third curve can join without a signature change. -enum class FadeCurve { EqualPower, Linear }; - -// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. -inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; - -// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration -// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances -// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). -enum class PitchEngine { Varispeed, Preserve }; - -// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" -// directive). ONE constant to flip if Varispeed should be the default instead. This is the -// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's -// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core -// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 -// engine" holds for the core's own regression tests (an octave up still halves duration in the -// bare engine); the Preserve product default is layered on above at (de)serialization. -inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; - -// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds -// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = -// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first -// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). -// One knob, resolved at voice allocation. -inline constexpr double kPreserveWindowMs = 50.0; - -// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always -// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to -// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack -// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). -struct PitchEnvParams { - bool enabled = false; - std::int64_t attackFrames = 0; - std::int64_t decayFrames = 0; - double peakSemitones = 0.0; // signed depth at the peak -}; - -// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData -// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 -// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope -// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the -// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one -// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. -struct ZonePlayParams { - PlayMode playMode = PlayMode::Gate; - AdsrParams adsr; // Gate: the AHDSR envelope - TriggerParams trigger; // Trigger: %-length + fades - PitchEngine pitchEngine = PitchEngine::Varispeed; - PitchEnvParams pitchEnv; // AD pitch modulation, off by default -}; - -// --------------------------------------------------------------------------- -// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that -// govern playback. The shell decodes the on-disk WAV and fills this; the core -// never touches a file. -// --------------------------------------------------------------------------- - -// A loop over [start, end) frames, half-open. A zero-length loop (start == end) -// is the "no sustain loop" marker — a held note past the sample end goes silent -// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. -struct SampleLoop { - bool hasLoop = false; - std::int64_t start = 0; // first looped frame (inclusive) - std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end -}; - -// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is -// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). -// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise -// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both -// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical -// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was -// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. -struct SampleData { - std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) - std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample - int sampleRate = 0; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch). - // 0 is explicitly invalid — every consumer must - // receive a real rate before use. - int rootNote = 60; // MIDI note recorded at (plays at unity here) - SampleLoop loop; // sustain loop, if any - // Initial read position (frame offset) a voice starts playback at — frame 0 by - // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this - // an instrument-side per-zone override (the "start point" marker); S15 builds on it - // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: - // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. - std::int64_t startFrame = 0; - - // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch - // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is - // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. - ZonePlayParams play; - - // 2 iff a matching-length second channel exists; else 1. A framesR of a different - // length than frames is treated as absent (mono) — a malformed pair never half-plays. - int channelCount() const { - return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; - } - -}; +// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode / +// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams, +// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header +// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits. // --------------------------------------------------------------------------- // Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves diff --git a/src/core/instrument/engine/zone_params.h b/src/core/instrument/engine/zone_params.h new file mode 100644 index 0000000..387375e --- /dev/null +++ b/src/core/instrument/engine/zone_params.h @@ -0,0 +1,192 @@ +#pragma once +// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the +// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor +// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec +// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes. +// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only. +// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the +// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h. + +#include +#include + +#include "core/audio/peaks.h" // AudioSample (float) + +namespace reasampler { + +// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own +// re-namespace lands; the deps live in their sub-namespace homes. +using audio::AudioSample; + +// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 +// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders +// per-channel. A PERFORMANCE choice the instrument owns (component state), never written +// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain +// value so the shell (bus negotiation, state) and the engine share one spelling; the core +// itself never branches on it — the mode only picks which render overload the shell drives. +enum class ChannelMode { Mono, Stereo }; + +// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's +// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE +// priority over a held-note stack (classic mono synth: a new note takes the voice over; the +// release of the top note falls back to the most-recent still-held note). A PERFORMANCE +// choice the instrument owns (component state), never a bank fact. Default Poly preserves +// current behavior. +enum class VoiceMode { Poly, Mono }; + +// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). +// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps +// the envelope running when a note is taken over while another is held — pitch moves without +// a re-attack (and the fallback on top-note release glides back the same way). Legato applies +// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts +// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample +// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. +enum class MonoTrigger { Retrigger, Legato }; + +// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. +// One spelling shared by the engine, the component-state (de)serializer, and the editor's +// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. +inline constexpr int kMinVoiceCount = 1; +inline constexpr int kMaxVoiceCount = 32; +inline constexpr int kDefaultVoiceCount = 16; + +// --------------------------------------------------------------------------- +// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because +// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching +// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower +// with the rest of the engine machinery; only the value structs need to precede SampleData. +// --------------------------------------------------------------------------- + +// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack +// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's +// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, +// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone +// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. +enum class PlayMode { Gate, Trigger }; + +// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span +// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), +// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over +// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play +// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. +struct TriggerParams { + double lengthFraction = 1.0; // (0,1] of the post-start span to play + std::int64_t fadeInFrames = 0; // 0->1 ramp at the head + std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd +}; + +// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default +// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) +// so a third curve can join without a signature change. +enum class FadeCurve { EqualPower, Linear }; + +// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. +inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; + +// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration +// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances +// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). +enum class PitchEngine { Varispeed, Preserve }; + +// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" +// directive). ONE constant to flip if Varispeed should be the default instead. This is the +// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's +// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core +// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 +// engine" holds for the core's own regression tests (an octave up still halves duration in the +// bare engine); the Preserve product default is layered on above at (de)serialization. +inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; + +// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds +// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = +// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first +// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). +// One knob, resolved at voice allocation. +inline constexpr double kPreserveWindowMs = 50.0; + +// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always +// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to +// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack +// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). +struct PitchEnvParams { + bool enabled = false; + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData +// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 +// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope +// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the +// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one +// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. +struct ZonePlayParams { + PlayMode playMode = PlayMode::Gate; + AdsrParams adsr; // Gate: the AHDSR envelope + TriggerParams trigger; // Trigger: %-length + fades + PitchEngine pitchEngine = PitchEngine::Varispeed; + PitchEnvParams pitchEnv; // AD pitch modulation, off by default +}; + +// --------------------------------------------------------------------------- +// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that +// govern playback. The shell decodes the on-disk WAV and fills this; the core +// never touches a file. +// --------------------------------------------------------------------------- + +// A loop over [start, end) frames, half-open. A zero-length loop (start == end) +// is the "no sustain loop" marker — a held note past the sample end goes silent +// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. +struct SampleLoop { + bool hasLoop = false; + std::int64_t start = 0; // first looped frame (inclusive) + std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end +}; + +// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is +// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). +// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise +// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both +// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical +// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was +// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. +struct SampleData { + std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) + std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample + int sampleRate = 0; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch). + // 0 is explicitly invalid — every consumer must + // receive a real rate before use. + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any + // Initial read position (frame offset) a voice starts playback at — frame 0 by + // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this + // an instrument-side per-zone override (the "start point" marker); S15 builds on it + // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: + // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. + std::int64_t startFrame = 0; + + // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch + // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is + // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. + ZonePlayParams play; + + // 2 iff a matching-length second channel exists; else 1. A framesR of a different + // length than frames is treated as absent (mono) — a malformed pair never half-plays. + int channelCount() const { + return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; + } + +}; + +} // namespace reasampler diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp new file mode 100644 index 0000000..2307c5f --- /dev/null +++ b/src/core/instrument/map/component_state_io.cpp @@ -0,0 +1,508 @@ +// component_state_io — the ComponentState envelope + zones-payload binary codec. See +// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7) +// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library + +// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve +// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer. + +#include "core/instrument/map/component_state_io.h" + +#include // std::min (bounded curve-point reserve) +#include // assert (v3-lift projectRate guard) +#include // std::isfinite (v8 master-gain validation) +#include // std::memcpy (serializeSelection) +#include // std::move + +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap +#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20) + +namespace reasampler::instrument::map { + +using engine::masterGainMaxLinear; +using reasampler::wire::ByteReader; +using reasampler::wire::bitsToDouble; +using reasampler::wire::doubleToBits; +using reasampler::wire::putLE; + +namespace { + +// Signed 64-bit values ride the wire as their two's-complement unsigned image. +std::uint64_t asU64(std::int64_t v) { return static_cast(v); } + +// Append the zones payload — the shared body of the performance blob and the component blob, +// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion +// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail +// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes +// the zone count so any reader can detect the record shape independently of the envelope version +// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip +// through EITHER envelope with no envelope bump. +void putZonesPayload(std::vector& out, const PerformanceMap& map) { + putLE(out, kZonesFormatMarker); + putLE(out, kZonesPayloadVersion); + putLE(out, static_cast(map.zones.size())); + for (const PerformanceZone& z : map.zones) { + putLE(out, static_cast(z.sampleId.size())); + out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); + putLE(out, static_cast(static_cast(z.lowNote))); + putLE(out, static_cast(static_cast(z.highNote))); + out.push_back(z.rootOverride ? 1 : 0); + if (z.rootOverride) { + putLE(out, + static_cast(static_cast(*z.rootOverride))); + } + // S11 extension: loop override (hasLoop flag + start/end), then start point. + out.push_back(z.loopOverride ? 1 : 0); + if (z.loopOverride) { + out.push_back(z.loopOverride->hasLoop ? 1 : 0); + putLE(out, asU64(z.loopOverride->start)); + putLE(out, asU64(z.loopOverride->end)); + } + out.push_back(z.startPoint ? 1 : 0); + if (z.startPoint) putLE(out, asU64(*z.startPoint)); + + // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). + // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / + // fraction. Order matches the header's v5 record spec. + const ZonePlaySeconds& pp = z.play; + out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); + putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds + putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction + putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames + putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames + out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); + out.push_back(pp.pitchEnv.enabled ? 1 : 0); + putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds + putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds + putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth + // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). + putLE(out, doubleToBits(pp.adsr.attackSeconds)); + putLE(out, doubleToBits(pp.adsr.decaySeconds)); + putLE(out, doubleToBits(pp.adsr.sustainLevel)); + putLE(out, doubleToBits(pp.adsr.releaseSeconds)); + // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET). + putLE(out, doubleToBits(z.keyTrack)); + // PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE + // control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included). + const std::vector& pts = z.velocityCurve.points(); + putLE(out, static_cast(pts.size())); + for (const VelocityPoint& p : pts) { + putLE(out, doubleToBits(p.velocity)); + putLE(out, doubleToBits(p.amp)); + } + } +} + +// Read a zones payload from `r` into `map`. Shared by the performance parse and the component +// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the +// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — +// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read +// keeps the zones that parsed cleanly and drops the rest. +// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame +// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / +// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. +void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { + bool extended = false; // v2+: the S11 loop/start tail is present + std::uint32_t pv = 0; // payload version (0 = v1, no marker) + if (r.peekU32() == kZonesFormatMarker) { + r.u32(); // consume the marker + pv = r.u32(); // payload version + extended = (pv >= 2); // v2+ carries the loop/start tail + } + const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames + const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds + const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar + const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last + const std::uint32_t count = r.u32(); + for (std::uint32_t i = 0; i < count && r.ok; ++i) { + // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A + // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). + PerformanceZone z; + const std::uint32_t idLen = r.u32(); + z.sampleId = r.str(idLen); + z.lowNote = r.i32(); + z.highNote = r.i32(); + const std::uint8_t hasOverride = r.u8(); + if (hasOverride) z.rootOverride = r.i32(); + if (extended) { + const std::uint8_t hasLoop = r.u8(); + if (hasLoop) { + SampleLoop lp; + lp.hasLoop = (r.u8() != 0); + lp.start = r.i64(); + lp.end = r.i64(); + z.loopOverride = lp; + } + const std::uint8_t hasStart = r.u8(); + if (hasStart) z.startPoint = r.i64(); + } + if (legacyV3Play) { + // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) + // were written as frames -> divide by the project sample rate (threaded in as `projectRate`) + // to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. + // A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. + assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); + const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first + z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; + z.play.adsr.holdSeconds = static_cast(r.i64()) / liftRate; + z.play.trigger.lengthFraction = bitsToDouble(r.u64()); + z.play.trigger.fadeInFrames = r.i64(); + z.play.trigger.fadeOutFrames = r.i64(); + z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; + z.play.pitchEnv.enabled = (r.u8() != 0); + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + } else if (secondsPlay) { + // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source + // frames; read in the emit order. + z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; + z.play.adsr.holdSeconds = bitsToDouble(r.u64()); + z.play.trigger.lengthFraction = bitsToDouble(r.u64()); + z.play.trigger.fadeInFrames = r.i64(); + z.play.trigger.fadeOutFrames = r.i64(); + z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; + z.play.pitchEnv.enabled = (r.u8() != 0); + z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64()); + z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64()); + z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); + z.play.adsr.attackSeconds = bitsToDouble(r.u64()); + z.play.adsr.decaySeconds = bitsToDouble(r.u64()); + z.play.adsr.sustainLevel = bitsToDouble(r.u64()); + z.play.adsr.releaseSeconds = bitsToDouble(r.u64()); + } + // PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6 + // payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an + // already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine. + if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); + // PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A + // pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 + // Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. + // fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips + // false mid-curve) leaves the flat default and the mid-zone break below drops the rest. + if (curveTail) { + const std::uint32_t ptCount = r.u32(); + std::vector pts; + // Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge + // count can't trigger a giant allocation before the bounded reads fail — the loop still + // stops on r.ok, this only caps the speculative reserve. + const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0; + pts.reserve(std::min(static_cast(ptCount), remaining / 16)); + for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { + const double vel = bitsToDouble(r.u64()); + const double amp = bitsToDouble(r.u64()); + pts.push_back(VelocityPoint{vel, amp}); + } + if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts)); + } + // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the + // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. + if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest + map.zones.push_back(std::move(z)); + } +} + +} // namespace + +std::vector serializePerformance(const PerformanceMap& map) { + std::vector out; + putLE(out, kPerformanceStateVersion); + putZonesPayload(out, map); + return out; +} + +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 + // blob is encountered with an invalid rate — the calller guarantees a real rate before use. + PerformanceMap map; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + if (!r.ok) return map; // no version tag -> empty + + // BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes, + // no length prefix). Lift it to one full-keyboard zone playing that id. + if (version == kSelectionStateVersion) { + const std::string id = deserializeSelection(bytes); + if (!id.empty()) { + PerformanceZone z; + z.sampleId = id; + z.lowNote = 0; + z.highNote = 127; + map.zones.push_back(std::move(z)); + } + return map; + } + if (version != kPerformanceStateVersion) return map; // unknown -> empty + + readZonesPayload(r, map, projectRate); + return map; +} + +// --- Combined component state (v3, S10) -------------------------------------- + +std::vector serializeComponentState(const ComponentState& state) { + std::vector out; + putLE(out, kComponentStateVersion); + // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. + out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); + // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE + // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that + // stops at the mode byte is a strict prefix (see the v4 lift below). + putLE(out, asU64(state.lastConsumedAssignGeneration)); + // v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows + // the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). + out.push_back(state.previewVelocity); + // v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, + // 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the + // velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift). + const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount + : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount + : state.voiceCount; + out.push_back(static_cast(vc)); + out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); + out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0); + // v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double + // (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to + // here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or + // negative falls back to unity; above the +24 dB cap clamps to the cap. + { + double g = state.masterGainLinear; + const double maxLin = masterGainMaxLinear(); + if (!std::isfinite(g) || g < 0.0) g = 1.0; + if (g > maxLin) g = maxLin; + putLE(out, doubleToBits(g)); + } + // v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, + // 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the + // v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's + // channel count); 1 = the user deliberately toggled the mode (never fought). + out.push_back(state.channelModeExplicit ? 1 : 0); + // v10 envelope addition (pS self-contained playback): the instance-owned sample-refs + // table, following the explicit flag so a v9 blob is a strict prefix up to here (see + // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per + // entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always + // written), channelCount, displayName (length-prefixed; display-only). + putLE(out, static_cast(state.sampleRefs.size())); + for (const SampleRefEntry& e : state.sampleRefs) { + putLE(out, static_cast(e.sampleId.size())); + out.insert(out.end(), e.sampleId.begin(), e.sampleId.end()); + putLE(out, static_cast(e.ref.relativePath.size())); + out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end()); + putLE(out, static_cast(static_cast(e.ref.rootNote))); + out.push_back(e.ref.loop.hasLoop ? 1 : 0); + putLE(out, asU64(e.ref.loop.start)); + putLE(out, asU64(e.ref.loop.end)); + putLE(out, + static_cast(static_cast(e.ref.channelCount))); + putLE(out, static_cast(e.displayName.size())); + out.insert(out.end(), e.displayName.begin(), e.displayName.end()); + } + // v11 envelope addition (pS-usage instance identity): the minted per-instance guid, + // length-prefixed, following the refs table so a v10 blob is a strict prefix up to + // here (see the v10 lift). Empty = never published — legal, round-trips as empty. + putLE(out, static_cast(state.instanceGuid.size())); + out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end()); + // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — + // unlike the v1 selection blob where the id ran to end-of-stream). + putLE(out, static_cast(state.selectionId.size())); + out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); + putZonesPayload(out, state.map); + return out; +} + +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. See readZonesPayload for the guard. + ComponentState out; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) + + // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. + // * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard + // zone} so the old pick survives as BOTH the selection and a one-zone map. + // * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate + // single-capture selection. + if (version == kSelectionStateVersion) { + out.selectionId = deserializeSelection(bytes); + if (!out.selectionId.empty()) { + PerformanceZone z; + z.sampleId = out.selectionId; + z.lowNote = 0; + z.highNote = 127; + out.map.zones.push_back(std::move(z)); + } + return out; + } + if (version == kPerformanceStateVersion) { + readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag + return out; // channelMode stays Mono (pre-S7) + } + // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — + // the id length + id + zones body starts right after the version tag (no mode byte). + if (version == kSelectionZonesV3Version) { + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map, projectRate); + return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) + } + // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): + // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration + // defaults to 0, so a first assign still applies for a pre-marker instance. + if (version == kSelectionZonesModeV4Version) { + const std::uint8_t modeByte = r.u8(); + if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) + out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map, projectRate); + return out; // marker stays 0 (pre-S8/S9 reader) + } + // BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity + // byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte. + // previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved + // pre-S-VIEW-4 instance restores at the mid default. + if (version == kSelectionZonesModeMarkerV5Version) { + const std::uint8_t modeByte = r.u8(); + if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) + out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; + out.lastConsumedAssignGeneration = r.i64(); + if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map, projectRate); + return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) + } + if (version != kComponentStateVersion && + version != kSelectionZonesRefsV10Version && + version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version && + version != kSelectionZonesModeMarkerVelVoiceGainV8Version && + version != kSelectionZonesModeMarkerVelVoiceV7Version && + version != kSelectionZonesModeMarkerVelV6Version) { + return out; // unknown -> empty + } + + // v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, + // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated + // as mono (conservative default) rather than rejected — a corrupt mode never silences the + // instance. + const std::uint8_t modeByte = r.u8(); + if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) + out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; + out.lastConsumedAssignGeneration = r.i64(); + if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) + const std::uint8_t previewVel = r.u8(); + if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds) + // Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a + // corrupt blob) falls back to the mid default rather than silencing the preview trigger. + out.previewVelocity = (previewVel >= 1 && previewVel <= 127) + ? previewVel + : kPreviewVelocityDefault; + // v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the + // construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. + if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { + const std::uint8_t vc = r.u8(); + const std::uint8_t vm = r.u8(); + const std::uint8_t mt = r.u8(); + if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold) + // Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent + // for a corrupt blob) rather than clamping to an edge the user never chose. + out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount) + ? static_cast(vc) + : kDefaultVoiceCount; + out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; + out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; + } + // v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction + // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or + // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. + if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { + const double g = bitsToDouble(r.u64()); + if (!r.ok) return out; // truncated inside the gain double — out already carries + // mode/marker/velocity/voice fields from above; unity holds + out.masterGainLinear = + (std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9)) + ? g + : 1.0; + } + // v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction + // default (false = implicit) holds, so an already-saved instance's mode is treated as the + // un-touched default and the shell may auto-default it from the loaded capture. + if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { + const std::uint8_t explicitByte = r.u8(); + if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) + out.channelModeExplicit = (explicitByte == 1); + } + // v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it — + // the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve + // path (then re-saves self-contained). A truncated mid-entry read keeps the entries that + // parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway). + if (version >= kSelectionZonesRefsV10Version) { + const std::uint32_t refCount = r.u32(); + for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { + SampleRefEntry e; + const std::uint32_t refIdLen = r.u32(); + e.sampleId = r.str(refIdLen); + const std::uint32_t pathLen = r.u32(); + e.ref.relativePath = r.str(pathLen); + // Range fallbacks (the refs table is the ONLY copy on the play path, so a + // corrupt field must degrade to the field's default, never poison playback — + // the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back + // to the middle-C default distill() uses; a negative channel count falls back + // to 0 = unknown (the GA auto-default then skips it). + const std::int32_t root = r.i32(); + e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; + e.ref.loop.hasLoop = (r.u8() != 0); + e.ref.loop.start = r.i64(); + e.ref.loop.end = r.i64(); + const std::int32_t channels = r.i32(); + e.ref.channelCount = channels >= 0 ? channels : 0; + const std::uint32_t nameLen = r.u32(); + e.displayName = r.str(nameLen); + if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest + out.sampleRefs.push_back(std::move(e)); + } + if (!r.ok) return out; + } + // v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the + // EMPTY default holds and the processor mints a fresh identity on first publish. + if (version >= kSelectionZonesRefsIdentityV11Version) { + const std::uint32_t guidLen = r.u32(); + out.instanceGuid = r.str(guidLen); + if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty + } + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map, projectRate); + return out; +} + +std::vector serializeSelection(const std::string& sampleId) { + std::vector out; + out.resize(4 + sampleId.size()); + const std::uint32_t v = kSelectionStateVersion; + out[0] = static_cast(v & 0xFF); + out[1] = static_cast((v >> 8) & 0xFF); + out[2] = static_cast((v >> 16) & 0xFF); + out[3] = static_cast((v >> 24) & 0xFF); + std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); + return out; +} + +std::string deserializeSelection(const std::vector& bytes) { + if (bytes.size() < 4) return {}; // no version tag -> no selection + const std::uint32_t v = static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); + if (v != kSelectionStateVersion) return {}; // unknown version -> ignore + return std::string(reinterpret_cast(bytes.data() + 4), + bytes.size() - 4); +} + +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h new file mode 100644 index 0000000..a66b1f4 --- /dev/null +++ b/src/core/instrument/map/component_state_io.h @@ -0,0 +1,326 @@ +#pragma once +// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the +// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO +// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps. +// +// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11 +// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's +// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop +// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset +// so the payload and the instrument's reader can never drift. Housing it inside +// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift) +// to serialize one preset blob; split out, both artifacts link the codec and only the +// VST links the engine. The codec's own links are velocity_curve + master_gain (wire +// value validation) — never the engine. +// +// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full +// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly. + +#include +#include +#include + +#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core) + +namespace reasampler::instrument::map { + +// --- Performance-map instance state (VST3 setState/getState) ----------------- +// +// The performance map is the instrument's OWN state (D-B), serialized to the VST3 +// component-state IBStream — NOT written to the "reasampler" bank ext-state (the +// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of +// truncation/wrong-version by design (bounded reads, never throws across the host). +// +// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the +// ZONES PAYLOAD. +// +// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones +// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) +// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState +// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the +// key composition property: the zone-record extension is versioned inside the map blob, not on +// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not +// collide on a single version number. +// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: +// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, +// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). +// A payload starting with a small u32 (the zone count) is v1 — there is no marker. +// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone +// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended +// to each zone record after rootOverride: +// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, +// 8-byte LE loop.end (both two's-complement int64); +// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). +// The reader detects the marker to know the record shape — a v1 payload (no marker) reads +// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload +// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint +// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; +// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); +// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. +// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + +// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved +// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. +// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS +// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds +// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed +// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. +// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). +// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), +// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full +// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine; 1 byte pitchEnv.enabled; +// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); +// 8-byte LE pitchEnv.peakSemitones (double); +// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); +// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). +// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 +// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader +// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds +// to frames at the LIVE sample rate; no rate is baked into storage or the program. +// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is +// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved +// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes +// to an EMPTY map. +// +// These two functions serialize the ZONES only. Since S10 the instrument's full component +// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState +// below, the v3 format the processor actually reads/writes. serializePerformance/ +// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. + +inline constexpr std::uint32_t kPerformanceStateVersion = 2; + +// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). +// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — +// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock +// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides +// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + +// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) +// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The +// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, +// always tiny) can never collide with. +// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the +// full v5 play-params tail: +// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). +// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone +// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the +// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. +// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp +// transfer curve appended to each zone record after the v6 keyTrack field: +// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp +// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. +// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 +// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: +// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A +// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed. +inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve +inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; + +// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are +// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a +// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build +// already receives, so the seconds domain is consistent across both paths. No constant is baked in. + +// The performance map serialized to bytes for IBStream (getState). +std::vector serializePerformance(const PerformanceMap& map); + +// The performance map parsed back from IBStream bytes (setState). A v2 blob parses +// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. +// `projectRate` is the live host/project sample rate (must be > 0) used to convert the +// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate); + +// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- +// +// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that +// BOTH persist: the default face is one picked capture (the selection id), and zones are a +// demoted opt-in overlay (the performance map). The component state carries both so a saved +// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an +// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty +// state), never auto-playing sample #1. +// +// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, +// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a +// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system +// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono +// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 +// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte +// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the +// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the +// instance-owned path + intrinsics + display name per referenced sample; wire shape at +// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE +// length + guid bytes; the minted per-instance identity the usage publisher keys its +// "rsusage_" ext-state record under, see sample_usage.h), then a 4-byte LE +// selection-id length + id bytes, then the CURRENT zones payload (identical to +// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). +// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the +// only v10 addition over v9 — the envelope grows a field, +// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own +// versioning; the two version numbers are independent axes — do NOT bump the zones-payload +// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range +// master-gain double (a corrupt blob) falls back to the field's default rather than silencing +// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to +// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = +// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity +// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the +// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD +// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — +// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path — +// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish): +// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. +// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. +// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). +// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). +// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). +// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). +// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). +// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. +// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. +// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). +// +// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is +// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user +// already got and then manually changed away from: on re-open the instance re-reads the pending +// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see +// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign +// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the +// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. +// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no +// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. +inline constexpr std::uint8_t kPreviewVelocityDefault = 64; + +struct ComponentState { + std::string selectionId; // the single-capture pick; "" = no pick + PerformanceMap map; // the opt-in zones; empty = no zones + ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) + // GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). + // While false (implicit), the shell auto-defaults the mode from the loaded capture's + // channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the + // user's choice is never fought. Pre-v9 blobs lift to false (implicit). + bool channelModeExplicit = false; + std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed + // S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling + // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's + // chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. + std::uint8_t previewVelocity = kPreviewVelocityDefault; + // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT + // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an + // older blob lifting to these plays byte-identically. + int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount + VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) + MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato + // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; + // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output + // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per + // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, + // so an older blob lifting to 1.0 plays exactly as it did. + double masterGainLinear = 1.0; + // pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics + // for every bank sample this instance plays (see the SampleRefs block above). setState + // decodes straight from these; NO bridge/extension read is required for playback. A + // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve + // path once (then re-saves self-contained). + SampleRefs sampleRefs; + // pS-usage (v11): the minted per-instance identity the usage publisher keys its + // "rsusage_" ext-state record under (see sample_usage.h — the prune-protection + // seam). Persisted so the key is stable across sessions (records do not proliferate + // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor + // mints one on first publish, and RE-mints when the publish plan detects this state + // was cloned onto another track (FX copy / track duplication — planUsagePublish). + std::string instanceGuid; +}; + +inline constexpr std::uint32_t kComponentStateVersion = 11; + +// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed +// after the refs table). Mirrors the v10/v9/… series so the version branches in +// deserializeComponentState stay self-describing. +inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; + +// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). +// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection +// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE +// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, +// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of +// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + +// displayName bytes (display-only; the editor label's extension-absent fallback). +inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; + +// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode +// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; + +// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the +// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; + +// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + +// preview velocity + voice system, no master gain). Retained so deserializeComponentState can +// lift a v7 blob to unity master gain. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; + +// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + +// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a +// v6 blob to the voice defaults {16, Poly, Retrigger}. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; + +// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no +// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. +inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; + +// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed +// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. +inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; + +// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named +// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. +inline constexpr std::uint32_t kSelectionZonesV3Version = 3; + +// The full instance state serialized to bytes for IBStream (getState). +std::vector serializeComponentState(const ComponentState& state); + +// The full instance state parsed back from IBStream bytes (setState). Tolerant of +// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table +// above so already-saved instances restore cleanly. +// `projectRate` is the live host/project sample rate (must be > 0) used to convert the +// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate); + +// --- Instance state (VST3 setState/getState) -------------------------------- +// +// The instrument's OWN state is which bank sample it plays (D-B: the selection is a +// performance choice, held by the instrument, never written back to the bank). It is a +// single string id. serialize/deserialize keep the on-the-wire form explicit and +// versioned so a future Tier can extend it without breaking already-saved instances. +// +// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No +// length prefix is needed — the id runs to the end of the stream (the host tells us the +// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob +// by returning "" (no selection — under the S10 policy reversal an empty selection is +// SILENCE + the "pick a capture" empty state, not the bank's first sample), never +// throwing across the host boundary. Retained for the v1→v3 back-compat lift in +// deserializeComponentState; the processor's live state is the v3 ComponentState above. + +inline constexpr std::uint32_t kSelectionStateVersion = 1; + +// The selected-sample id serialized to bytes for IBStream (getState). +std::vector serializeSelection(const std::string& sampleId); + +// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, +// too-short, or empty -> "" (graceful no-selection). +std::string deserializeSelection(const std::vector& bytes); + + +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 7ae5813..cfb35be 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -1,19 +1,14 @@ -// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / -// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. +// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec +// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / +// SWELL / vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. #include "core/instrument/map/sample_map.h" #include // std::min #include // assert -#include // std::isfinite (v8 master-gain validation) -#include // std::memcpy #include // std::move -#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap - -namespace reasampler { - -using instrument::engine::masterGainMaxLinear; +namespace reasampler::instrument::map { namespace { @@ -405,568 +400,5 @@ Keymap buildZonedKeymap(const std::vector& zones, return km; // empty zones in -> empty Keymap (silence) } -// --- Performance-map instance state (setState/getState) ----------------------- -namespace { - -void putU32le(std::vector& out, std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); -} - -// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as -// two's-complement u64, mirroring the u32 signed-int idiom above). -void putU64le(std::vector& out, std::uint64_t v) { - for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); -} - -std::uint64_t asU64(std::int64_t v) { return static_cast(v); } - -// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++). -// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields. -std::uint64_t doubleToBits(double d) { - std::uint64_t bits; - std::memcpy(&bits, &d, sizeof(bits)); - return bits; -} -double bitsToDouble(std::uint64_t bits) { - double d; - std::memcpy(&d, &bits, sizeof(d)); - return d; -} - -// A bounded little-endian reader over a byte blob. Every read is length-checked; once a -// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated -// blob degrades to a partial/empty parse rather than reading out of bounds. -struct ByteReader { - const std::vector& bytes; - std::size_t pos = 0; - bool ok = true; - - explicit ByteReader(const std::vector& b) : bytes(b) {} - - std::uint32_t u32() { - if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } - const std::uint32_t v = static_cast(bytes[pos]) | - (static_cast(bytes[pos + 1]) << 8) | - (static_cast(bytes[pos + 2]) << 16) | - (static_cast(bytes[pos + 3]) << 24); - pos += 4; - return v; - } - std::uint8_t u8() { - if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; } - return bytes[pos++]; - } - std::string str(std::uint32_t len) { - if (!ok || pos + len > bytes.size()) { ok = false; return {}; } - std::string s(reinterpret_cast(bytes.data() + pos), len); - pos += len; - return s; - } - // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). - int i32() { return static_cast(static_cast(u32())); } - - std::uint64_t u64() { - if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; } - std::uint64_t v = 0; - for (int b = 0; b < 8; ++b) - v |= static_cast(bytes[pos + static_cast(b)]) << (b * 8); - pos += 8; - return v; - } - // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width). - std::int64_t i64() { return static_cast(u64()); } - - // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields - // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob - // as "no marker" and falls through to the (also-guarded) v1 count read. - std::uint32_t peekU32() const { - if (!ok || pos + 4 > bytes.size()) return 0; - return static_cast(bytes[pos]) | - (static_cast(bytes[pos + 1]) << 8) | - (static_cast(bytes[pos + 2]) << 16) | - (static_cast(bytes[pos + 3]) << 24); - } -}; - -// Append the zones payload — the shared body of the performance blob and the component blob, -// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion -// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail -// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes -// the zone count so any reader can detect the record shape independently of the envelope version -// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip -// through EITHER envelope with no envelope bump. -void putZonesPayload(std::vector& out, const PerformanceMap& map) { - putU32le(out, kZonesFormatMarker); - putU32le(out, kZonesPayloadVersion); - putU32le(out, static_cast(map.zones.size())); - for (const PerformanceZone& z : map.zones) { - putU32le(out, static_cast(z.sampleId.size())); - out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); - putU32le(out, static_cast(static_cast(z.lowNote))); - putU32le(out, static_cast(static_cast(z.highNote))); - out.push_back(z.rootOverride ? 1 : 0); - if (z.rootOverride) { - putU32le(out, - static_cast(static_cast(*z.rootOverride))); - } - // S11 extension: loop override (hasLoop flag + start/end), then start point. - out.push_back(z.loopOverride ? 1 : 0); - if (z.loopOverride) { - out.push_back(z.loopOverride->hasLoop ? 1 : 0); - putU64le(out, asU64(z.loopOverride->start)); - putU64le(out, asU64(z.loopOverride->end)); - } - out.push_back(z.startPoint ? 1 : 0); - if (z.startPoint) putU64le(out, asU64(*z.startPoint)); - - // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). - // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / - // fraction. Order matches the header's v5 record spec. - const ZonePlaySeconds& pp = z.play; - out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); - putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction - putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames - putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames - out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); - out.push_back(pp.pitchEnv.enabled ? 1 : 0); - putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth - // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). - putU64le(out, doubleToBits(pp.adsr.attackSeconds)); - putU64le(out, doubleToBits(pp.adsr.decaySeconds)); - putU64le(out, doubleToBits(pp.adsr.sustainLevel)); - putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); - // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET). - putU64le(out, doubleToBits(z.keyTrack)); - // PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE - // control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included). - const std::vector& pts = z.velocityCurve.points(); - putU32le(out, static_cast(pts.size())); - for (const VelocityPoint& p : pts) { - putU64le(out, doubleToBits(p.velocity)); - putU64le(out, doubleToBits(p.amp)); - } - } -} - -// Read a zones payload from `r` into `map`. Shared by the performance parse and the component -// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the -// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — -// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read -// keeps the zones that parsed cleanly and drops the rest. -// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame -// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / -// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. -void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { - bool extended = false; // v2+: the S11 loop/start tail is present - std::uint32_t pv = 0; // payload version (0 = v1, no marker) - if (r.peekU32() == kZonesFormatMarker) { - r.u32(); // consume the marker - pv = r.u32(); // payload version - extended = (pv >= 2); // v2+ carries the loop/start tail - } - const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames - const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds - const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar - const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last - const std::uint32_t count = r.u32(); - for (std::uint32_t i = 0; i < count && r.ok; ++i) { - // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A - // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). - PerformanceZone z; - const std::uint32_t idLen = r.u32(); - z.sampleId = r.str(idLen); - z.lowNote = r.i32(); - z.highNote = r.i32(); - const std::uint8_t hasOverride = r.u8(); - if (hasOverride) z.rootOverride = r.i32(); - if (extended) { - const std::uint8_t hasLoop = r.u8(); - if (hasLoop) { - SampleLoop lp; - lp.hasLoop = (r.u8() != 0); - lp.start = r.i64(); - lp.end = r.i64(); - z.loopOverride = lp; - } - const std::uint8_t hasStart = r.u8(); - if (hasStart) z.startPoint = r.i64(); - } - if (legacyV3Play) { - // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) - // were written as frames -> divide by the project sample rate (threaded in as `projectRate`) - // to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. - // A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. - assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); - const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first - z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; - z.play.adsr.holdSeconds = static_cast(r.i64()) / liftRate; - z.play.trigger.lengthFraction = bitsToDouble(r.u64()); - z.play.trigger.fadeInFrames = r.i64(); - z.play.trigger.fadeOutFrames = r.i64(); - z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; - z.play.pitchEnv.enabled = (r.u8() != 0); - z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / liftRate; - z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / liftRate; - z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); - } else if (secondsPlay) { - // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source - // frames; read in the emit order. - z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; - z.play.adsr.holdSeconds = bitsToDouble(r.u64()); - z.play.trigger.lengthFraction = bitsToDouble(r.u64()); - z.play.trigger.fadeInFrames = r.i64(); - z.play.trigger.fadeOutFrames = r.i64(); - z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; - z.play.pitchEnv.enabled = (r.u8() != 0); - z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64()); - z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64()); - z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); - z.play.adsr.attackSeconds = bitsToDouble(r.u64()); - z.play.adsr.decaySeconds = bitsToDouble(r.u64()); - z.play.adsr.sustainLevel = bitsToDouble(r.u64()); - z.play.adsr.releaseSeconds = bitsToDouble(r.u64()); - } - // PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6 - // payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an - // already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine. - if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); - // PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A - // pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 - // Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. - // fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips - // false mid-curve) leaves the flat default and the mid-zone break below drops the rest. - if (curveTail) { - const std::uint32_t ptCount = r.u32(); - std::vector pts; - // Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge - // count can't trigger a giant allocation before the bounded reads fail — the loop still - // stops on r.ok, this only caps the speculative reserve. - const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0; - pts.reserve(std::min(static_cast(ptCount), remaining / 16)); - for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { - const double vel = bitsToDouble(r.u64()); - const double amp = bitsToDouble(r.u64()); - pts.push_back(VelocityPoint{vel, amp}); - } - if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts)); - } - // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the - // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. - if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest - map.zones.push_back(std::move(z)); - } -} - -} // namespace - -std::vector serializePerformance(const PerformanceMap& map) { - std::vector out; - putU32le(out, kPerformanceStateVersion); - putZonesPayload(out, map); - return out; -} - -PerformanceMap deserializePerformance(const std::vector& bytes, - double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 - // blob is encountered with an invalid rate — the calller guarantees a real rate before use. - PerformanceMap map; - ByteReader r(bytes); - const std::uint32_t version = r.u32(); - if (!r.ok) return map; // no version tag -> empty - - // BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes, - // no length prefix). Lift it to one full-keyboard zone playing that id. - if (version == kSelectionStateVersion) { - const std::string id = deserializeSelection(bytes); - if (!id.empty()) { - PerformanceZone z; - z.sampleId = id; - z.lowNote = 0; - z.highNote = 127; - map.zones.push_back(std::move(z)); - } - return map; - } - if (version != kPerformanceStateVersion) return map; // unknown -> empty - - readZonesPayload(r, map, projectRate); - return map; -} - -// --- Combined component state (v3, S10) -------------------------------------- - -std::vector serializeComponentState(const ComponentState& state) { - std::vector out; - putU32le(out, kComponentStateVersion); - // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. - out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); - // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE - // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that - // stops at the mode byte is a strict prefix (see the v4 lift below). - putU64le(out, asU64(state.lastConsumedAssignGeneration)); - // v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows - // the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). - out.push_back(state.previewVelocity); - // v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, - // 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the - // velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift). - const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount - : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount - : state.voiceCount; - out.push_back(static_cast(vc)); - out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); - out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0); - // v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double - // (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to - // here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or - // negative falls back to unity; above the +24 dB cap clamps to the cap. - { - double g = state.masterGainLinear; - const double maxLin = masterGainMaxLinear(); - if (!std::isfinite(g) || g < 0.0) g = 1.0; - if (g > maxLin) g = maxLin; - putU64le(out, doubleToBits(g)); - } - // v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, - // 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the - // v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's - // channel count); 1 = the user deliberately toggled the mode (never fought). - out.push_back(state.channelModeExplicit ? 1 : 0); - // v10 envelope addition (pS self-contained playback): the instance-owned sample-refs - // table, following the explicit flag so a v9 blob is a strict prefix up to here (see - // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per - // entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always - // written), channelCount, displayName (length-prefixed; display-only). - putU32le(out, static_cast(state.sampleRefs.size())); - for (const SampleRefEntry& e : state.sampleRefs) { - putU32le(out, static_cast(e.sampleId.size())); - out.insert(out.end(), e.sampleId.begin(), e.sampleId.end()); - putU32le(out, static_cast(e.ref.relativePath.size())); - out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end()); - putU32le(out, static_cast(static_cast(e.ref.rootNote))); - out.push_back(e.ref.loop.hasLoop ? 1 : 0); - putU64le(out, asU64(e.ref.loop.start)); - putU64le(out, asU64(e.ref.loop.end)); - putU32le(out, - static_cast(static_cast(e.ref.channelCount))); - putU32le(out, static_cast(e.displayName.size())); - out.insert(out.end(), e.displayName.begin(), e.displayName.end()); - } - // v11 envelope addition (pS-usage instance identity): the minted per-instance guid, - // length-prefixed, following the refs table so a v10 blob is a strict prefix up to - // here (see the v10 lift). Empty = never published — legal, round-trips as empty. - putU32le(out, static_cast(state.instanceGuid.size())); - out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end()); - // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — - // unlike the v1 selection blob where the id ran to end-of-stream). - putU32le(out, static_cast(state.selectionId.size())); - out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); - putZonesPayload(out, state.map); - return out; -} - -ComponentState deserializeComponentState(const std::vector& bytes, - double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. See readZonesPayload for the guard. - ComponentState out; - ByteReader r(bytes); - const std::uint32_t version = r.u32(); - if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) - - // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. - // * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard - // zone} so the old pick survives as BOTH the selection and a one-zone map. - // * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate - // single-capture selection. - if (version == kSelectionStateVersion) { - out.selectionId = deserializeSelection(bytes); - if (!out.selectionId.empty()) { - PerformanceZone z; - z.sampleId = out.selectionId; - z.lowNote = 0; - z.highNote = 127; - out.map.zones.push_back(std::move(z)); - } - return out; - } - if (version == kPerformanceStateVersion) { - readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag - return out; // channelMode stays Mono (pre-S7) - } - // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — - // the id length + id + zones body starts right after the version tag (no mode byte). - if (version == kSelectionZonesV3Version) { - const std::uint32_t idLen = r.u32(); - out.selectionId = r.str(idLen); - if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map, projectRate); - return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) - } - // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): - // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration - // defaults to 0, so a first assign still applies for a pre-marker instance. - if (version == kSelectionZonesModeV4Version) { - const std::uint8_t modeByte = r.u8(); - if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) - out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; - const std::uint32_t idLen = r.u32(); - out.selectionId = r.str(idLen); - if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map, projectRate); - return out; // marker stays 0 (pre-S8/S9 reader) - } - // BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity - // byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte. - // previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved - // pre-S-VIEW-4 instance restores at the mid default. - if (version == kSelectionZonesModeMarkerV5Version) { - const std::uint8_t modeByte = r.u8(); - if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) - out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; - out.lastConsumedAssignGeneration = r.i64(); - if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) - const std::uint32_t idLen = r.u32(); - out.selectionId = r.str(idLen); - if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map, projectRate); - return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) - } - if (version != kComponentStateVersion && - version != kSelectionZonesRefsV10Version && - version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version && - version != kSelectionZonesModeMarkerVelVoiceGainV8Version && - version != kSelectionZonesModeMarkerVelVoiceV7Version && - version != kSelectionZonesModeMarkerVelV6Version) { - return out; // unknown -> empty - } - - // v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, - // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated - // as mono (conservative default) rather than rejected — a corrupt mode never silences the - // instance. - const std::uint8_t modeByte = r.u8(); - if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) - out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; - out.lastConsumedAssignGeneration = r.i64(); - if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) - const std::uint8_t previewVel = r.u8(); - if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds) - // Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a - // corrupt blob) falls back to the mid default rather than silencing the preview trigger. - out.previewVelocity = (previewVel >= 1 && previewVel <= 127) - ? previewVel - : kPreviewVelocityDefault; - // v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the - // construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. - if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { - const std::uint8_t vc = r.u8(); - const std::uint8_t vm = r.u8(); - const std::uint8_t mt = r.u8(); - if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold) - // Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent - // for a corrupt blob) rather than clamping to an edge the user never chose. - out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount) - ? static_cast(vc) - : kDefaultVoiceCount; - out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; - out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; - } - // v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction - // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or - // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. - if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { - const double g = bitsToDouble(r.u64()); - if (!r.ok) return out; // truncated inside the gain double — out already carries - // mode/marker/velocity/voice fields from above; unity holds - out.masterGainLinear = - (std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9)) - ? g - : 1.0; - } - // v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction - // default (false = implicit) holds, so an already-saved instance's mode is treated as the - // un-touched default and the shell may auto-default it from the loaded capture. - if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { - const std::uint8_t explicitByte = r.u8(); - if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) - out.channelModeExplicit = (explicitByte == 1); - } - // v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it — - // the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve - // path (then re-saves self-contained). A truncated mid-entry read keeps the entries that - // parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway). - if (version >= kSelectionZonesRefsV10Version) { - const std::uint32_t refCount = r.u32(); - for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { - SampleRefEntry e; - const std::uint32_t refIdLen = r.u32(); - e.sampleId = r.str(refIdLen); - const std::uint32_t pathLen = r.u32(); - e.ref.relativePath = r.str(pathLen); - // Range fallbacks (the refs table is the ONLY copy on the play path, so a - // corrupt field must degrade to the field's default, never poison playback — - // the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back - // to the middle-C default distill() uses; a negative channel count falls back - // to 0 = unknown (the GA auto-default then skips it). - const std::int32_t root = r.i32(); - e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; - e.ref.loop.hasLoop = (r.u8() != 0); - e.ref.loop.start = r.i64(); - e.ref.loop.end = r.i64(); - const std::int32_t channels = r.i32(); - e.ref.channelCount = channels >= 0 ? channels : 0; - const std::uint32_t nameLen = r.u32(); - e.displayName = r.str(nameLen); - if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest - out.sampleRefs.push_back(std::move(e)); - } - if (!r.ok) return out; - } - // v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the - // EMPTY default holds and the processor mints a fresh identity on first publish. - if (version >= kSelectionZonesRefsIdentityV11Version) { - const std::uint32_t guidLen = r.u32(); - out.instanceGuid = r.str(guidLen); - if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty - } - const std::uint32_t idLen = r.u32(); - out.selectionId = r.str(idLen); - if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map, projectRate); - return out; -} - -std::vector serializeSelection(const std::string& sampleId) { - std::vector out; - out.resize(4 + sampleId.size()); - const std::uint32_t v = kSelectionStateVersion; - out[0] = static_cast(v & 0xFF); - out[1] = static_cast((v >> 8) & 0xFF); - out[2] = static_cast((v >> 16) & 0xFF); - out[3] = static_cast((v >> 24) & 0xFF); - std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); - return out; -} - -std::string deserializeSelection(const std::vector& bytes) { - if (bytes.size() < 4) return {}; // no version tag -> no selection - const std::uint32_t v = static_cast(bytes[0]) | - (static_cast(bytes[1]) << 8) | - (static_cast(bytes[2]) << 16) | - (static_cast(bytes[3]) << 24); - if (v != kSelectionStateVersion) return {}; // unknown version -> ignore - return std::string(reinterpret_cast(bytes.data() + 4), - bytes.size() - 4); -} - -} // namespace reasampler +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 76764b6..cf06733 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -27,10 +27,10 @@ #include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop #include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) -namespace reasampler { +namespace reasampler::instrument::map { -// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map -// re-namespaces in its own split wave (Q-W2v). +// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in +// instrument::map; the engine family stays in flat `reasampler` until its own wave). using audio::AudioSample; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; @@ -413,302 +413,10 @@ Keymap buildZonedKeymap(const std::vector& zones, DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate); -// --- Performance-map instance state (VST3 setState/getState) ----------------- -// -// The performance map is the instrument's OWN state (D-B), serialized to the VST3 -// component-state IBStream — NOT written to the "reasampler" bank ext-state (the -// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of -// truncation/wrong-version by design (bounded reads, never throws across the host). -// -// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the -// ZONES PAYLOAD. -// -// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones -// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) -// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState -// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the -// key composition property: the zone-record extension is versioned inside the map blob, not on -// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not -// collide on a single version number. -// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: -// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, -// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). -// A payload starting with a small u32 (the zone count) is v1 — there is no marker. -// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone -// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended -// to each zone record after rootOverride: -// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, -// 8-byte LE loop.end (both two's-complement int64); -// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). -// The reader detects the marker to know the record shape — a v1 payload (no marker) reads -// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. -// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload -// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint -// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; -// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); -// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); -// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. -// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + -// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved -// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. -// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS -// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds -// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed -// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. -// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). -// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), -// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full -// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine; 1 byte pitchEnv.enabled; -// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); -// 8-byte LE pitchEnv.peakSemitones (double); -// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); -// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). -// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 -// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader -// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds -// to frames at the LIVE sample rate; no rate is baked into storage or the program. -// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is -// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved -// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes -// to an EMPTY map. -// -// These two functions serialize the ZONES only. Since S10 the instrument's full component -// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState -// below, the v3 format the processor actually reads/writes. serializePerformance/ -// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. +// The ComponentState envelope + zones-payload binary codec (serializePerformance / +// serializeComponentState / serializeSelection + the deserializers and every version +// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows +// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the +// split lets both artifacts share the codec while only the VST links the voice engine. -inline constexpr std::uint32_t kPerformanceStateVersion = 2; - -// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). -// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — -// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock -// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides -// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + -// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) -// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The -// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, -// always tiny) can never collide with. -// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the -// full v5 play-params tail: -// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). -// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone -// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the -// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. -// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp -// transfer curve appended to each zone record after the v6 keyTrack field: -// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp -// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. -// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 -// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: -// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A -// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed. -inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve -inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; - -// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are -// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a -// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build -// already receives, so the seconds domain is consistent across both paths. No constant is baked in. - -// The performance map serialized to bytes for IBStream (getState). -std::vector serializePerformance(const PerformanceMap& map); - -// The performance map parsed back from IBStream bytes (setState). A v2 blob parses -// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. -// `projectRate` is the live host/project sample rate (must be > 0) used to convert the -// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. -PerformanceMap deserializePerformance(const std::vector& bytes, - double projectRate); - -// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- -// -// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that -// BOTH persist: the default face is one picked capture (the selection id), and zones are a -// demoted opt-in overlay (the performance map). The component state carries both so a saved -// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an -// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty -// state), never auto-playing sample #1. -// -// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, -// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a -// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system -// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono -// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 -// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte -// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the -// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the -// instance-owned path + intrinsics + display name per referenced sample; wire shape at -// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE -// length + guid bytes; the minted per-instance identity the usage publisher keys its -// "rsusage_" ext-state record under, see sample_usage.h), then a 4-byte LE -// selection-id length + id bytes, then the CURRENT zones payload (identical to -// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). -// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the -// only v10 addition over v9 — the envelope grows a field, -// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own -// versioning; the two version numbers are independent axes — do NOT bump the zones-payload -// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range -// master-gain double (a corrupt blob) falls back to the field's default rather than silencing -// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to -// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = -// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity -// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the -// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD -// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — -// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path — -// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish): -// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. -// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. -// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). -// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). -// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). -// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). -// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). -// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). -// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. -// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. -// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). -// -// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is -// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user -// already got and then manually changed away from: on re-open the instance re-reads the pending -// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see -// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign -// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the -// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. -// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no -// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. -inline constexpr std::uint8_t kPreviewVelocityDefault = 64; - -struct ComponentState { - std::string selectionId; // the single-capture pick; "" = no pick - PerformanceMap map; // the opt-in zones; empty = no zones - ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) - // GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). - // While false (implicit), the shell auto-defaults the mode from the loaded capture's - // channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the - // user's choice is never fought. Pre-v9 blobs lift to false (implicit). - bool channelModeExplicit = false; - std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed - // S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling - // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's - // chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. - std::uint8_t previewVelocity = kPreviewVelocityDefault; - // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT - // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an - // older blob lifting to these plays byte-identically. - int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount - VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) - MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato - // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; - // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output - // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per - // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, - // so an older blob lifting to 1.0 plays exactly as it did. - double masterGainLinear = 1.0; - // pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics - // for every bank sample this instance plays (see the SampleRefs block above). setState - // decodes straight from these; NO bridge/extension read is required for playback. A - // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve - // path once (then re-saves self-contained). - SampleRefs sampleRefs; - // pS-usage (v11): the minted per-instance identity the usage publisher keys its - // "rsusage_" ext-state record under (see sample_usage.h — the prune-protection - // seam). Persisted so the key is stable across sessions (records do not proliferate - // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor - // mints one on first publish, and RE-mints when the publish plan detects this state - // was cloned onto another track (FX copy / track duplication — planUsagePublish). - std::string instanceGuid; -}; - -inline constexpr std::uint32_t kComponentStateVersion = 11; - -// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed -// after the refs table). Mirrors the v10/v9/… series so the version branches in -// deserializeComponentState stay self-describing. -inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; - -// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). -// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection -// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE -// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, -// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of -// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + -// displayName bytes (display-only; the editor label's extension-absent fallback). -inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; - -// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode -// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; - -// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the -// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; - -// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + -// preview velocity + voice system, no master gain). Retained so deserializeComponentState can -// lift a v7 blob to unity master gain. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; - -// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + -// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a -// v6 blob to the voice defaults {16, Poly, Retrigger}. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; - -// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no -// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. -inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; - -// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed -// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. -inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; - -// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named -// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. -inline constexpr std::uint32_t kSelectionZonesV3Version = 3; - -// The full instance state serialized to bytes for IBStream (getState). -std::vector serializeComponentState(const ComponentState& state); - -// The full instance state parsed back from IBStream bytes (setState). Tolerant of -// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table -// above so already-saved instances restore cleanly. -// `projectRate` is the live host/project sample rate (must be > 0) used to convert the -// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. -ComponentState deserializeComponentState(const std::vector& bytes, - double projectRate); - -// --- Instance state (VST3 setState/getState) -------------------------------- -// -// The instrument's OWN state is which bank sample it plays (D-B: the selection is a -// performance choice, held by the instrument, never written back to the bank). It is a -// single string id. serialize/deserialize keep the on-the-wire form explicit and -// versioned so a future Tier can extend it without breaking already-saved instances. -// -// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No -// length prefix is needed — the id runs to the end of the stream (the host tells us the -// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob -// by returning "" (no selection — under the S10 policy reversal an empty selection is -// SILENCE + the "pick a capture" empty state, not the bank's first sample), never -// throwing across the host boundary. Retained for the v1→v3 back-compat lift in -// deserializeComponentState; the processor's live state is the v3 ComponentState above. - -inline constexpr std::uint32_t kSelectionStateVersion = 1; - -// The selected-sample id serialized to bytes for IBStream (getState). -std::vector serializeSelection(const std::string& sampleId); - -// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, -// too-short, or empty -> "" (graceful no-selection). -std::string deserializeSelection(const std::vector& bytes); - -} // namespace reasampler +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/ui/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp index 3a7f9ad..c88ef1b 100644 --- a/src/core/instrument/ui/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -3,6 +3,8 @@ #include "core/instrument/ui/browser_scroll.h" +#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist) + #include #include @@ -155,4 +157,25 @@ std::vector filterNameIndices(const std::vector& names, return out; } +// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim; +// the band metrics come from editor_geometry, the search height from searchBoxRect). +BrowseModal computeBrowseModal(int w, int h) { + constexpr int kBrowseFooterH = 30; + BrowseModal m; + const int titleH = (std::min)(kTitleHeight, h); + m.title = Rect::ltrb(0, 0, w, titleH); + m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)); + // Search box below the title, spanning the width (searchBoxRect lays it out from 0). + const Rect sb = searchBoxRect(w); + m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height); + const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH); + m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop); + // Footer: Cancel (left) + Load (right). + const int fTop = footerTop + 3; + const int fBot = (std::max)(fTop, h - 3); + m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot); + m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot); + return m; +} + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/browser_scroll.h b/src/core/instrument/ui/browser_scroll.h index 641d409..566ee69 100644 --- a/src/core/instrument/ui/browser_scroll.h +++ b/src/core/instrument/ui/browser_scroll.h @@ -104,4 +104,21 @@ bool nameMatchesQuery(const std::string& name, const std::string& query); std::vector filterNameIndices(const std::vector& names, const std::string& query); +// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) ------- +// +// A title band with a Back button, the search box, the browser sub-area (tabs + card +// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker +// covers the full window (F3: full-window overlay). Draw + hit-test both derive from +// this single layout so they never drift. Homed here (not editor_geometry) because the +// search-box height feeds it — browser_scroll already owns the search/scroll geometry. +struct BrowseModal { + Rect title; + Rect back; // the "Back" title-band button + Rect search; // the type-to-filter box (absolute) + Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin + Rect cancel; // footer Cancel + Rect confirm; // footer Load (confirm) +}; +BrowseModal computeBrowseModal(int w, int h); + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp index 90ccfe5..4cbc809 100644 --- a/src/core/instrument/ui/editor_geometry.cpp +++ b/src/core/instrument/ui/editor_geometry.cpp @@ -159,4 +159,150 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { return contains(layout.addZoneButton, x, y); } +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the +// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's +// kDeckKnobSize read directly — passed in so this module stays knob_deck-free). + +namespace { + +// Fixed band metrics (formerly the editor shell's anon-ns constants). +constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) +constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle +constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) + +// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial +// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). +constexpr int kPreviewBtnW = 64; +constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) +constexpr int kCurveBtnSize = 28; // the square curve-preview button + +// The S7 mono/stereo toggle segments. +constexpr int kChanSegW = 52; +constexpr int kChanSegH = 18; + +} // namespace + +// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed +// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- +// anchored). When the window is too short for the floor (below the checkSizeConstraint +// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the +// window bottom gracefully. +SampleBands computeSampleBands(int w, int h, int deckH) { + SampleBands b; + const int titleH = (std::min)(kTitleHeight, h); + b.title = Rect::ltrb(0, 0, w, titleH); + // Two nav buttons right-anchored in the title band (Browse then Zone). + const int navTop = 2; + const int navBot = (std::max)(navTop, titleH - 2); + const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot); + const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot); + b.navBrowse = browse; + b.navZone = zone; + + int deckTop = h - kPad - deckH; + int clusterTop = deckTop - kClusterHeight - 4; + int heroBottom = clusterTop - 4; + if (heroBottom - titleH < kHeroMinHeight) { + heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below + clusterTop = heroBottom + 4; + deckTop = clusterTop + kClusterHeight + 4; + } + b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom); + b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight); + b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH); + return b; +} + +// Draw + hit-test both derive from this ONE formula. +ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) { + ClusterRects r; + const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; + const int stripBot = stripTop + kStripBandHeight; + const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2; + r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop, + chanMono.x - kPad, curveTop + kCurveBtnSize); + r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop, + r.curveBtn.x - kPad, stripBot); + const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2; + r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize, + r.velCell.y + knobSize); + r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom()); + r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop, + r.velCell.x - kPad, stripBot); + r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot); + return r; +} + +ChannelToggleRects channelToggleRects(const Rect& area) { + const int top = area.y + (area.height - kChanSegH) / 2; + const int right = area.right() - kPad; + const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH); + const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH); + return {mono, stereo}; +} + +Rect zoneContentArea(int w, int h) { + const int titleH = (std::min)(kTitleHeight, h); + return Rect::ltrb(0, titleH, w, h); +} + +Rect zoneBackRect(int w, int h) { + return Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, + (std::max)(2, (std::min)(kTitleHeight, h) - 2)); +} + +Rect zoneAddRect(const Rect& content) { + return Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96, + content.y + 4 + 20); +} + +Rect zoneDeleteRect(const Rect& addR) { + return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); +} + +// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px +// gap, padded kPad horizontally. All call sites use this formula. +Rect zonesStripArea(const Rect& content) { + const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 + return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, + stripTop + kStripBandHeight); +} + +// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom +// without re-inlining the strip arithmetic here. +Rect noteEntryFieldsArea(const Rect& content) { + const int stripBottom = zonesStripArea(content).bottom(); + const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) + return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18); +} + +Rect noteEntryFieldRect(const Rect& fields, int f) { + if (f < 0 || f > 2 || fields.width <= 0) return Rect{}; + const int segW = fields.width / 3; + const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap + const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW; + return Rect::ltrb(left, fields.y, right, fields.bottom()); +} + +Rect zonesControlPanel(const Rect& content) { + const Rect strip = zonesStripArea(content); + const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap + return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad, + content.bottom() - 4); +} + +// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a +// column at the panel's right reserved for the mini curve-preview button so no deck row +// starts inside it. +Rect zonesDeckArea(const Rect& content) { + const Rect panel = zonesControlPanel(content); + return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); +} + +Rect zonesCurveButton(const Rect& content) { + const Rect panel = zonesControlPanel(content); + return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize); +} + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.h b/src/core/instrument/ui/editor_geometry.h index a0c7ff9..b1e8239 100644 --- a/src/core/instrument/ui/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -139,4 +139,86 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int // True if (x, y) lands on the "Add Zone" button. Pure. bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// +// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the +// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and +// hit-test both derive every rect from these ONE formulas so they can never drift; the +// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search +// box height feeds it — dependency-clean placement beside its scroll/search siblings). + +// Shared band metrics (the shell's remaining direct uses: horizontal padding + the +// title-band height; everything else is internal to the layout functions below). +inline constexpr int kPad = 8; +inline constexpr int kTitleHeight = 26; +inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons + +// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the +// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor +// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK +// (height `deckH` from the pure knob_deck wrap). When the window is too short for the +// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its +// floor and the lower bands clip past the window bottom gracefully. +struct SampleBands { + Rect title; // top: name + Browse/Zone nav buttons + Rect navBrowse; // the "Browse" title-band button + Rect navZone; // the "Zone" title-band button + Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay + Rect cluster; // root strip + preview + vel knob + curve button + channel toggle + Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) +}; +SampleBands computeSampleBands(int w, int h, int deckH); + +// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the +// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve +// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's +// kDeckKnobSize — passed in so this module does not depend on knob_deck). +struct ClusterRects { + Rect rootStrip; // remainder-width fenced root strip + Rect preview; // the preview-trigger button + Rect velCell; // the radial preview-velocity knob cell (knob + label band) + Rect velKnob; // the knob square at the cell's top + Rect velLabel; // the label band beneath it + Rect curveBtn; // the mini curve-preview button (opens the popup) +}; +ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); + +// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically +// centered. Returns {mono-segment, stereo-segment}, side by side. +struct ChannelToggleRects { + Rect mono; + Rect stereo; +}; +ChannelToggleRects channelToggleRects(const Rect& area); + +// The Zone-view (S-VIEW-8) content area: the whole window below the title band. +Rect zoneContentArea(int w, int h); + +// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample +// face's Zone nav button occupies). +Rect zoneBackRect(int w, int h); + +// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button +// beside it (Delete only draws/hits when a zone is selected). +Rect zoneAddRect(const Rect& content); +Rect zoneDeleteRect(const Rect& addR); + +// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, +// padded kPad horizontally. +Rect zonesStripArea(const Rect& content); + +// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of +// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — +// three equal segments left-to-right. An out-of-range index yields an empty rect. +Rect noteEntryFieldsArea(const Rect& content); +Rect noteEntryFieldRect(const Rect& fields, int f); + +// The per-zone parameter panel below the strip + the one-line legend, running to the +// content bottom; the FB2 knob-deck area within it (a column at the right reserved for +// the mini curve-preview button); and that button's rect (the cluster's 28px square, +// right-anchored at the panel top). +Rect zonesControlPanel(const Rect& content); +Rect zonesDeckArea(const Rect& content); +Rect zonesCurveButton(const Rect& content); + } // namespace reasampler::instrument::ui diff --git a/src/core/wire/bytes.h b/src/core/wire/bytes.h new file mode 100644 index 0000000..db4cff8 --- /dev/null +++ b/src/core/wire/bytes.h @@ -0,0 +1,110 @@ +// core/wire/bytes.h — the ONE little-endian byte codec (Q-W2v; audit T4-20). +// Pure, header-only: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// Five hand-rolled LE copies existed at the Q-W0 census (sample_map's +// putU32le/putU64le + ByteReader, capture_realtime's writeU32LE, capture_paths' +// readU32LE lambda, ingest's putU32 lambda, instrument_drop's appendU32LE). This +// template is the single survivor: compile-time dispatched, zero runtime cost, +// entirely off hot paths (serialization / file I/O only). The ComponentState +// codec (component_state_io) is its biggest consumer; the remaining hand-rolled +// copies rewire opportunistically in the waves that already open their files. +// +// Wire formats are FROZEN: putLE/putLE emit exactly the bytes the +// retired putU32le/putU64le emitted (LSB first, fixed width), and ByteReader +// preserves the latch-on-truncation contract (once a read runs past the end, +// ok latches false and every subsequent read yields zeros/empties — a truncated +// blob degrades to a partial parse, never out-of-bounds). + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace reasampler::wire { + +// Append `v` little-endian (LSB first, sizeof(T) bytes). Unsigned integral types +// only — signed values go on the wire as their two's-complement unsigned image +// (cast at the call site, the established idiom: u32 for int, u64 for int64). +template +inline void putLE(std::vector& out, T v) { + static_assert(std::is_unsigned_v, "putLE takes the unsigned wire image"); + for (std::size_t b = 0; b < sizeof(T); ++b) { + out.push_back(static_cast((v >> (b * 8)) & 0xFF)); + } +} + +// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined +// type-pun in C++17). Doubles ride the wire as their u64 bit image via putLE. +inline std::uint64_t doubleToBits(double d) { + std::uint64_t bits; + std::memcpy(&bits, &d, sizeof(bits)); + return bits; +} +inline double bitsToDouble(std::uint64_t bits) { + double d; + std::memcpy(&d, &bits, sizeof(d)); + return d; +} + +// A bounded little-endian reader over a byte blob. Every read is length-checked; +// once a read runs past the end the reader latches `ok=false` and yields zeros, +// so a truncated blob degrades to a partial/empty parse rather than reading out +// of bounds. (The class formerly private to sample_map.cpp, promoted here as the +// codec's tested primitive — T4-20.) +struct ByteReader { + const std::vector& bytes; + std::size_t pos = 0; + bool ok = true; + + explicit ByteReader(const std::vector& b) : bytes(b) {} + + // Read one unsigned integral little-endian (fixed sizeof(T) width). + template + T readLE() { + static_assert(std::is_unsigned_v, "readLE yields the unsigned wire image"); + if (!ok || pos + sizeof(T) > bytes.size()) { + ok = false; + return 0; + } + T v = 0; + for (std::size_t b = 0; b < sizeof(T); ++b) { + v |= static_cast(bytes[pos + b]) << (b * 8); + } + pos += sizeof(T); + return v; + } + + std::uint8_t u8() { return readLE(); } + std::uint32_t u32() { return readLE(); } + std::uint64_t u64() { return readLE(); } + // Signed ints ride the wire as fixed-width two's-complement unsigned images. + int i32() { return static_cast(static_cast(u32())); } + std::int64_t i64() { return static_cast(u64()); } + + std::string str(std::uint32_t len) { + if (!ok || pos + len > bytes.size()) { + ok = false; + return {}; + } + std::string s(reinterpret_cast(bytes.data() + pos), len); + pos += len; + return s; + } + + // Non-consuming peek of the next u32 (format-marker probes). Yields 0 and + // latches nothing when fewer than 4 bytes remain — the caller treats a short + // blob as "no marker" and falls through to its (also-guarded) fallback read. + std::uint32_t peekU32() const { + if (!ok || pos + 4 > bytes.size()) return 0; + return static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } +}; + +} // namespace reasampler::wire diff --git a/src/core/wire/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp index e475830..78c1b0a 100644 --- a/src/core/wire/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -7,25 +7,19 @@ #include #include "core/wire/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID -#include "core/instrument/map/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) +#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer, Q-W2v codec split) +#include "core/wire/bytes.h" // putLE — the ONE LE byte codec (T4-20) namespace reasampler::wire { +using instrument::map::ComponentState; +using instrument::map::serializeComponentState; + namespace { -// Little-endian appenders — the .vstpreset container stores its integers little-endian on -// disk (public.sdk vstpresetfile.cpp swaps only on big-endian hosts). -void appendU32LE(std::vector& out, std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); -} - -void appendU64LE(std::vector& out, std::uint64_t v) { - for (int i = 0; i < 8; ++i) - out.push_back(static_cast((v >> (8 * i)) & 0xFF)); -} +// The .vstpreset container stores its integers little-endian on disk (public.sdk +// vstpresetfile.cpp swaps only on big-endian hosts) — putLE (core/wire/bytes.h) is +// exactly that byte order; the former appendU32LE/appendU64LE copies are retired (T4-20). void appendFourCC(std::vector& out, const char id[4]) { out.insert(out.end(), id, id + 4); @@ -58,19 +52,19 @@ std::vector buildVstPresetBytes( out.reserve(static_cast(listOffset) + 4 + 4 + (4 + 8 + 8)); appendFourCC(out, "VST3"); - appendU32LE(out, 1); // kFormatVersion + putLE(out, 1); // kFormatVersion out.insert(out.end(), classIdHex32.begin(), classIdHex32.end()); - appendU64LE(out, listOffset); + putLE(out, listOffset); // Data area: the one 'Comp' chunk's bytes, at offset kHeaderSize. out.insert(out.end(), componentState.begin(), componentState.end()); // Chunk list: 'List' + entry count + one entry {'Comp', offset, size}. appendFourCC(out, "List"); - appendU32LE(out, 1); + putLE(out, 1); appendFourCC(out, "Comp"); - appendU64LE(out, kHeaderSize); - appendU64LE(out, compSize); + putLE(out, kHeaderSize); + putLE(out, compSize); return out; } diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp new file mode 100644 index 0000000..56892de --- /dev/null +++ b/src/shell/instrument/editor_controls.cpp @@ -0,0 +1,397 @@ +// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of +// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue / +// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck +// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack +// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl. +// Value logic only — no painting, no window plumbing. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include +#include // snprintf (deck value labels) +#include +#include + +#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) +#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3) +#include "core/util/clamp01.h" +#include "shell/instrument/editor_internal.h" // DeckGroup ids +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters +using instrument::engine::formatMasterGainLabel; +using instrument::engine::masterGainLinearFromNorm; +using instrument::engine::masterGainNormFromLinear; +using util::clamp01; + +namespace { +// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is +// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span +// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build +// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) +// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is +// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — +// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the +// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate +// ruling). Build-time residual — one place to retune; not persisted. +constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) +constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) +constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered +constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) + +} // namespace + +double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { + // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over + // the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate. + // A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 + // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old + // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. + const double fadeMax = fadeMaxFrames(); + const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; + const auto framesToNorm = [fadeMax](std::int64_t f) { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); + case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); + case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); + case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); + case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); + case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); + case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); + case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); + case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; + case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); + case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); + case ParamControl::kPitchEnvDepth: + // Signed depth centered at 0.5 (0.5 == 0 semitones). + return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + default: return 0.0; + } +} + +void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, + int segment) const { + const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale + const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; + const auto normToFrames = [fadeMax](double v) -> std::int64_t { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + if (fadeMax <= 0.0) return 0; + return static_cast(clamp01(v) * fadeMax + 0.5); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: + play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; + break; + case ParamControl::kPitchEngine: + play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; + break; + case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; + case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; + case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; + case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; + case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; + case ParamControl::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 ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; + case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; + case ParamControl::kPitchEnvEnable: + play.pitchEnv.enabled = (segment == 1); + break; + case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; + case ParamControl::kPitchEnvDepth: + play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + break; + default: break; + } +} + +double ReaSamplerEditor::liveSampleRate() const { + return processor_ ? processor_->sampleRate() : 0.0; +} + +double ReaSamplerEditor::fadeMaxFrames() const { + // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) + // resolved against the live rate — the SAME time base the envelope overlay already uses + // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), + // and the rate captures are made at (the capture path renders at the project rate). + // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the + // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — + // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert + // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. + const double rate = liveSampleRate(); + if (rate <= 0.0) return 0.0; + return kFadeMaxSeconds * rate; +} + +double ReaSamplerEditor::previewVelocity01() const { + if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; + return static_cast(processor_->previewVelocity()) / 127.0; +} + +std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { + // The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders + // exactly these; the Sample face appends the per-instance groups in deckGroupDescs). + // Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger + // leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows + // the neighbouring groups (r11). + std::vector out; + { + DeckGroupDesc amp; + amp.id = kGroupAmpEnv; + amp.captionWidth = 78; + amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; + if (play.playMode == PlayMode::Gate) { + amp.cellIds = {static_cast(ParamControl::kAttack), + static_cast(ParamControl::kHold), + static_cast(ParamControl::kDecay), + static_cast(ParamControl::kSustain), + static_cast(ParamControl::kRelease)}; + } else { + // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — + // matches the drawn envelope), plus the two reserved blanks. + amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), + static_cast(ParamControl::kTrigLength), + static_cast(ParamControl::kTrigFadeOut), -1, -1}; + } + out.push_back(std::move(amp)); + } + { + DeckGroupDesc pitch; + pitch.id = kGroupPitch; + pitch.captionWidth = 38; + pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; + pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; + out.push_back(std::move(pitch)); + } + { + DeckGroupDesc penv; + penv.id = kGroupPitchEnv; + penv.captionWidth = 58; + penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; + penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), + static_cast(ParamControl::kPitchEnvDecay), + static_cast(ParamControl::kPitchEnvDepth)}; + out.push_back(std::move(penv)); + } + return out; +} + +std::vector ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const { + // The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER + // groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the + // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays + // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. + std::vector out = zoneDeckGroupDescs(play); + { + DeckGroupDesc voice; + voice.id = kGroupVoice; + voice.captionWidth = 38; + voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; + voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; + voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; + out.push_back(std::move(voice)); + } + { + DeckGroupDesc master; + master.id = kGroupMaster; + master.captionWidth = 46; + master.cellIds = {static_cast(ParamControl::kMasterGain)}; + out.push_back(std::move(master)); + } + return out; +} + +double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { + if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob + switch (static_cast(id)) { + case ParamControl::kKeyTrack: + return clamp01(zone.keyTrack / kKeyTrackMax); + case ParamControl::kVoiceCount: + return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / + static_cast(kMaxVoiceCount - kMinVoiceCount)); + case ParamControl::kMasterGain: + return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0); + default: + return controlValue(id, zone.play); + } +} + +void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { + if (!processor_) return; + norm = clamp01(norm); + if (id == -2) { + // Preview velocity: live processor write (persisted per-instance; the setter clamps + // to MIDI 1..127 so the knob's bottom still strikes audibly). + processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); + return; + } + switch (static_cast(id)) { + case ParamControl::kVoiceCount: { + // Stepped: quantize the continuous drag to the integer count and track it live + // for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on + // WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild, + // not thirty. + const int count = + kMinVoiceCount + + static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); + voiceCount_ = count; + return; + } + case ParamControl::kMasterGain: + // Post-mixer gain: one atomic store; the audio thread picks it up next block. + processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); + return; + default: + applyZoneControl(zoneIndex, id, norm, 0); + return; + } +} + +std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { + char buf[24]; + buf[0] = '\0'; + const ZonePlaySeconds& play = zone.play; + switch (id == -2 ? ParamControl::kCount : static_cast(id)) { + case ParamControl::kAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; + case ParamControl::kHold: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; + case ParamControl::kDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; + case ParamControl::kSustain: + snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; + case ParamControl::kRelease: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; + case ParamControl::kTrigLength: + snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; + case ParamControl::kTrigFadeIn: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeInFrames)); break; + case ParamControl::kTrigFadeOut: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeOutFrames)); break; + case ParamControl::kPitchEnvAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; + case ParamControl::kPitchEnvDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; + case ParamControl::kPitchEnvDepth: + snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; + case ParamControl::kKeyTrack: + snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break; + case ParamControl::kVoiceCount: + snprintf(buf, sizeof(buf), "%d", voiceCount_); break; + case ParamControl::kMasterGain: + formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; + default: + // -2 (preview velocity) is labeled at its cluster call site; nothing else here. + break; + } + return std::string(buf); +} + +EnvClampBounds ReaSamplerEditor::envClampBounds() const { + // Match the control-panel sliders' own domains so a node drag can never produce a param a + // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the + // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). + EnvClampBounds b; + b.maxAttackSeconds = kEnvTimeMaxSeconds; + b.maxHoldSeconds = kEnvTimeMaxSeconds; + b.maxDecaySeconds = kEnvTimeMaxSeconds; + b.maxReleaseSeconds = kEnvTimeMaxSeconds; + b.maxFadeInFraction = 1.0; + b.maxFadeOutFraction = 1.0; + b.maxLengthFraction = 1.0; + return b; +} + +AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, + std::int64_t startFrame) const { + AmpEnvelope env; + env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; + // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). + env.attackSeconds = play.adsr.attackSeconds; + env.holdSeconds = play.adsr.holdSeconds; + env.decaySeconds = play.adsr.decaySeconds; + env.sustainLevel = play.adsr.sustainLevel; + env.releaseSeconds = play.adsr.releaseSeconds; + // Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played + // span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start + // point so the fraction denominator matches the voice's actual post-start span. A zero play + // length yields 0 fractions. + env.lengthFraction = play.trigger.lengthFraction; + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); + env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); + return env; +} + +void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, + std::int64_t startFrame, ZonePlaySeconds& play) const { + if (env.mode == EnvMode::Gate) { + play.adsr.attackSeconds = env.attackSeconds; + play.adsr.holdSeconds = env.holdSeconds; + play.adsr.decaySeconds = env.decaySeconds; + play.adsr.sustainLevel = env.sustainLevel; + play.adsr.releaseSeconds = env.releaseSeconds; + } else { + // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over + // the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's + // effective start point so the frame denominator matches the voice's actual post-start span. + // Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length + // trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); + play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); + } +} + +PerformanceZone ReaSamplerEditor::popupZone() const { + // The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample + // face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex). + if (view_ == View::kZone && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + return map_.zones[static_cast(selectedZone_)]; + } + return effectiveSampleZone(); +} + +int ReaSamplerEditor::popupZoneIndex() { + // The map_.zones index a popup edit lands on, or -1 when there is no valid target. The + // Zone surface never materializes (the button only shows for an explicit selection); the + // Sample face finds-or-materializes the picked id's one-zone site. + if (view_ == View::kZone) { + return (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) + ? selectedZone_ + : -1; + } + return ensureSampleZone(); +} + +#ifdef _WIN32 +void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) { + if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; + PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; + if (id == static_cast(ParamControl::kKeyTrack)) { + // keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1. + z.keyTrack = clamp01(value) * kKeyTrackMax; + } else { + applyControl(id, z.play, value, segment); + } +} +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_input_browse_zone.cpp b/src/shell/instrument/editor_input_browse_zone.cpp new file mode 100644 index 0000000..063b592 --- /dev/null +++ b/src/shell/instrument/editor_input_browse_zone.cpp @@ -0,0 +1,440 @@ +// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE +// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 hover +// resolution across all three faces, the Browse picker's click branch (tabs, cards, +// select-then-confirm, scroll-thumb grab, search focus), the Zone surface's click branch +// (add/delete, strip drags, numeric-entry focus, per-zone deck + curve button), the +// browser wheel scroll, the type-to-filter / note-entry keystrokes, and the S13 degraded +// drop affordance. Windows-only (D5). + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover) +#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize +#include "core/instrument/map/note_entry.h" // parseNoteEntry (S12 numeric entry) +#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover) +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; +using namespace reasampler::instrument::ui; +using namespace reasampler::instrument::map; + +// --- Hover resolution (Phase L, L3) ------------------------------------------ +// +// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an +// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only. +void ReaSamplerEditor::resolveHover(int x, int y) { + HoverTarget h; // kNone by default + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int hgt = cr.bottom - cr.top; + + if (view_ == View::kBrowse) { + const BrowseModal bm = computeBrowseModal(w, hgt); + if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1}; + else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1}; + else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1}; + else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1}; + else { + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + const int bx = x - bm.content.x; + const int by = y - bm.content.y; + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + const int card = (tab >= 0) + ? -1 + : cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); + if (tab >= 0) h = {HoverKind::kFilterTab, tab}; + else if (card >= 0) h = {HoverKind::kCard, card}; + } + } else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2) + const CurvePopupLayout pl = computeCurvePopup(w, hgt); + if (contains(pl.close, x, y)) { + h = {HoverKind::kPopupClose, -1}; + } else if (contains(pl.curveBox, x, y)) { + // A curve node under the pointer lights accent-hot. + const int idx = + popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); + if (idx >= 0) h = {HoverKind::kCurveNode, idx}; + } + } else if (view_ == View::kZone) { + const Rect back = zoneBackRect(w, hgt); + const Rect content = zoneContentArea(w, hgt); + Rect addR = zoneAddRect(content); + Rect delR = zoneDeleteRect(addR); + if (contains(back, x, y)) { + h = {HoverKind::kBack, -1}; + } else if (contains(addR, x, y)) { + h = {HoverKind::kAddZone, -1}; + } else if (selectedZone_ >= 0 && contains(delR, x, y)) { + h = {HoverKind::kDeleteZone, -1}; + } else if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + // FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's + // hover grammar — knobs light + swap label->value). + if (contains(zonesCurveButton(content), x, y)) { + h = {HoverKind::kCurveButton, -1}; + } else { + const ZonePlaySeconds& play = + map_.zones[static_cast(selectedZone_)].play; + const Rect deckArea = zonesDeckArea(content); + const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, + deckArea.y, deckArea.width); + const DeckHit dh = hitTestDeck(dl, x, y); + if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; + } + } + } else { // Sample view (home, r11 recomposition) + const PerformanceZone zone = effectiveSampleZone(); + const std::vector descs = deckGroupDescs(zone.play); + const SampleBands bands = + computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad)); + if (contains(bands.navBrowse, x, y)) { + h = {HoverKind::kNavBrowse, -1}; + } else if (contains(bands.navZone, x, y)) { + h = {HoverKind::kNavZone, -1}; + } else if (selectedId_.empty() && map_.zones.empty()) { + // Empty state — no interactive surfaces beyond the nav. + } else { + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); + if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1}; + else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1}; + else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1}; + else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; + else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; + else if (contains(bands.deck, x, y)) { + // A deck knob/toggle under the pointer: knobs light + swap label->value. + const DeckLayout dl = + layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width); + const DeckHit dh = hitTestDeck(dl, x, y); + if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; + } + } + } + + if (h != hover_) { + hover_ = h; + invalidate(); + } +} + +// The Browse-modal branch of the mouse-down dispatch (formerly inline in onMouseDown — +// behavior-identical; see editor_input_sample.cpp for the dispatch). +void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) { + const BrowseModal bm = computeBrowseModal(w, h); + if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { + // Cancel/Back: discard the pending pick, return to Sample unchanged. + browsePendingId_.clear(); + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + return; + } + if (contains(bm.confirm, x, y)) { + // Load: commit the pending pick (if any) into the loaded selection + reload, then Sample. + if (!browsePendingId_.empty()) { + loadSelection(browsePendingId_); + } + browsePendingId_.clear(); + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + return; + } + if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } + searchFocused_ = false; + + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + const int bx = x - bm.content.x; + const int by = y - bm.content.y; + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + if (tab >= 0) { + activeFilterBankId_ = (tab == 0) ? std::string() + : banks_[static_cast(tab - 1)].id; + rebuildVisible(); + invalidate(); + return; + } + const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); + if (thumb.height > 0 && + contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y, + thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) { + drag_ = DragKind::kScrollThumb; + dragStartY_ = y; + dragStartScrollOffset_ = scrollOffset_; + return; + } + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); + if (card >= 0) { + // Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card + // is the load accelerator (commit + dismiss). Browse never loads on a single click. + const std::string id = visible_[static_cast(card)].id; + if (lastBrowseClickCard_ == card && browsePendingId_ == id) { + loadSelection(id); + browsePendingId_.clear(); + lastBrowseClickCard_ = -1; + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + } else { + browsePendingId_ = id; + lastBrowseClickCard_ = card; + invalidate(); + } + return; + } + lastBrowseClickCard_ = -1; + return; +} + +// The Zone-surface branch of the mouse-down dispatch (formerly the tail of onMouseDown — +// behavior-identical; the curve popup is modal over the Zone surface too, FB2). +void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { + if (handlePopupMouseDown(w, h, x, y)) return; + const Rect back = zoneBackRect(w, h); + if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } + const Rect content = zoneContentArea(w, h); + const int pad = 8; + Rect addR = zoneAddRect(content); + if (contains(addR, x, y)) { + // Add a narrow default zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id + // already exists (pre-fix bleed survivor), select it rather than appending a duplicate + // (mirrors the upsert the root-marker drag path already performs). + // NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped + // to [0,127]) so the new zone is immediately "authored" (narrow) and survives + // reconcileSingleCaptureZones without being treated as a Sample-face full-range zone. + std::string seed = !selectedId_.empty() ? selectedId_ + : (!visible_.empty() ? visible_.front().id : std::string()); + if (seed.empty()) return; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { + selectedZone_ = i; + invalidate(); + return; + } + } + // Look up the seed's root note from the browser list (absent root defaults to 60). + int seedRoot = 60; + for (const SampleChoice& sc : samples_) { + if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; } + } + const int lo = (std::max)(0, seedRoot - 6); + const int hi = (std::min)(127, seedRoot + 5); + PerformanceZone z; + z.sampleId = seed; + z.lowNote = lo; + z.highNote = hi; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + commitAndReload(); + return; + } + Rect delR = zoneDeleteRect(addR); + if (selectedZone_ >= 0 && contains(delR, x, y)) { + map_.zones.erase(map_.zones.begin() + selectedZone_); + selectedZone_ = -1; + commitAndReload(); + return; + } + + // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the + // selected zone's root. + const Rect stripArea = zonesStripArea(content); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int lx = x - stripArea.x; + const int ly = y - stripArea.y; + + std::vector lows, highs; + lows.reserve(map_.zones.size()); + highs.reserve(map_.zones.size()); + for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } + const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), + highs.empty() ? nullptr : highs.data(), + static_cast(map_.zones.size()), lx, ly); + if (hit.zoneIndex >= 0) { + selectedZone_ = hit.zoneIndex; + const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; + dragStartX_ = x; + dragStartLow_ = z.lowNote; + dragStartHigh_ = z.highNote; + dragStartMap_ = map_; + switch (hit.grab) { + case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; + case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; + case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; + default: drag_ = DragKind::kNone; break; + } + invalidate(); + return; + } + // A bare key-click inside the strip sets the selected zone's root override. + if (contains(stripArea, x, y) && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + const int note = keyAtPoint(sl, lx, ly); + if (note >= 0) { + map_.zones[static_cast(selectedZone_)].rootOverride = note; + commitAndReload(); + } + return; + } + + // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a + // zone is selected. entryText_ starts empty (the user types the full value). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const Rect fields = noteEntryFieldsArea(content); + for (int f = 0; f < 3; ++f) { + if (contains(noteEntryFieldRect(fields, f), x, y)) { + entryField_ = f; + entryText_.clear(); + invalidate(); + return; + } + } + } + entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry + + // The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the + // SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected + // (the Zone surface has no single-capture fallback — that lives on the Sample face). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + if (contains(zonesCurveButton(content), x, y)) { + curvePopupOpen_ = true; + invalidate(); + return; + } + const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; + const Rect deckArea = zonesDeckArea(content); + const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y, + deckArea.width); + const DeckHit hit = hitTestDeck(dl, x, y); + if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { + // Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete, + // final edit committed at once (the deck precedent). No per-instance ids reach + // here — VOICE/MASTER are not in the zone group set. + applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment); + commitAndReload(); + return; + } + if (hit.kind == DeckHitKind::Knob) { + // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the + // Sample deck's guard, mirrored. + const bool pitchEnvKnob = + hit.id == static_cast(ParamControl::kPitchEnvAttack) || + hit.id == static_cast(ParamControl::kPitchEnvDecay) || + hit.id == static_cast(ParamControl::kPitchEnvDepth); + if (pitchEnvKnob && !play.pitchEnv.enabled) return; + // GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release. + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = selectedZone_; + dragStartMap_ = map_; + dragKnobStartValue_ = deckControlNorm( + hit.id, map_.zones[static_cast(selectedZone_)]); + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + } + } +} + +void ReaSamplerEditor::onMouseWheel(int delta) { + // Browser scroll (only in the Browse modal — the sole card grid). One wheel notch + // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive + // delta (wheel up) scrolls toward the top (smaller offset). + if (view_ != View::kBrowse) return; + const int rows = delta / 120; + if (rows == 0) return; + scrollOffset_ -= rows * kBrowserCardHeight; + if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content + invalidate(); +} + +void ReaSamplerEditor::onSearchChar(unsigned int ch) { + // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face + // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search + // cannot hold focus under it). + if (curvePopupOpen_ && ch == 27) { + curvePopupOpen_ = false; + invalidate(); + return; + } + + // S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes + // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when + // focused, owns the keystrokes). + if (view_ == View::kZone && entryField_ >= 0) { + if (ch == 13) { // Enter: parse + commit + if (auto note = parseNoteEntry(entryText_)) { + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); + else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); + else z.rootOverride = *note; + commitAndReload(); + } + } + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 27) { // Escape cancels + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 8) { // backspace + if (!entryText_.empty()) entryText_.pop_back(); + invalidate(); + } else if (ch >= 32 && ch < 127) { + entryText_.push_back(static_cast(ch)); + invalidate(); + } + return; + } + + // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace + // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). + if (view_ != View::kBrowse || !searchFocused_) return; + if (ch == 8) { // backspace + if (!searchQuery_.empty()) searchQuery_.pop_back(); + } else if (ch == 27) { // escape clears + defocuses + searchQuery_.clear(); + searchFocused_ = false; + } else if (ch >= 32 && ch < 127) { + searchQuery_.push_back(static_cast(ch)); + } else { + return; // ignore other control chars + } + scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list + rebuildVisible(); + invalidate(); +} + +void ReaSamplerEditor::onFilesDropped(int droppedCount) { + // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact + // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff + // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a + // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing + // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs + // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. + (void)droppedCount; // count is informational; the banner text is drop-count-agnostic + dropHintTicks_ = 6; +#ifdef _WIN32 + invalidate(); +#endif +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_input_sample.cpp b/src/shell/instrument/editor_input_sample.cpp new file mode 100644 index 0000000..b236d76 --- /dev/null +++ b/src/shell/instrument/editor_input_sample.cpp @@ -0,0 +1,586 @@ +// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state +// machine (Q-W2v split of reasampler_editor.cpp, T4-11): the mouse-down dispatch (the +// Sample-face branch inline; Browse/Zone branches delegate to editor_input_browse_zone), +// the curve-popup/curve-box click machinery, the live drag resolution (onMouseMove — deck +// knobs, root marker, envelope nodes, curve nodes, wave markers, scroll thumb, zone +// edges), the release commit (onMouseUp), and the popup right-click delete. Windows-only +// (D5). All hit-test math is pure; this TU routes and mutates editor state only. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag) +#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (r11) +#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3) +#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize +#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag) +#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11) +#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; +using namespace reasampler::instrument::ui; +using namespace reasampler::instrument::map; + +bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { + // The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home + // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash + // click dismiss (outside only when no drag is in flight, per the spec); in-box clicks + // route to the shared curve machinery against popupZoneIndex(); anything else on the + // sheet is swallowed. + if (!curvePopupOpen_) return false; + const CurvePopupLayout pl = computeCurvePopup(w, h); + if (contains(pl.close, x, y)) { + curvePopupOpen_ = false; + invalidate(); + return true; + } + if (contains(pl.curveBox, x, y)) { + const int zi = popupZoneIndex(); + if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y); + return true; + } + if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) { + curvePopupOpen_ = false; + invalidate(); + } + return true; +} + +void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) { + if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; + const VelocityCurve::Box box = curveBoxFromRect(r); + if (box.width <= 0 || box.height <= 1) return; + PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; + + int idx = z.velocityCurve.pointAtPixel(box, x, y); + + // Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once + // (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). + if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { + if (z.velocityCurve.deletePoint(static_cast(idx))) { + selectedZone_ = zoneIndex; + commitAndReload(); + } + return; + } + + // Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight + // ADD (mirror of the other map-editing drags' dragStartMap_ contract). + dragStartMap_ = map_; + + // Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure + // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller + // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the + // caption band — must not add a point; a click there would clamp to velocity 0/127 and + // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab + // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is + // handled above; only the add path is box-gated here. + if (idx < 0) { + const bool inBox = (x >= box.left && x < box.left + box.width && + y >= box.top && y < box.top + box.height); + if (inBox) { + const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y); + idx = static_cast(z.velocityCurve.addPoint(p.velocity, p.amp)); + } + } + + if (idx < 0) return; // ring click with no node hit — nothing to grab + + drag_ = DragKind::kCurveNode; + curvePointIndex_ = idx; + dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base + dragCurveRect_ = r; + dragCurveZone_ = zoneIndex; + dragStartX_ = x; + dragStartY_ = y; + selectedZone_ = zoneIndex; + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP +} + +// --- Input: the drag-state machine ------------------------------------------- + +void ReaSamplerEditor::onMouseDown(int x, int y) { + if (!processor_) return; + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + + // ---- Browse modal (S-VIEW-5): the face branch lives in editor_input_browse_zone ---- + if (view_ == View::kBrowse) { + mouseDownBrowse(w, h, x, y); + return; + } + + // ---- Sample home (S-VIEW-2 / r11) ---- + if (view_ == View::kSample) { + // r11 curve popup: while open the sheet is modal — it owns every left-click. + if (handlePopupMouseDown(w, h, x, y)) return; + + const PerformanceZone probeZone = effectiveSampleZone(); + const std::vector deckDescs = deckGroupDescs(probeZone.play); + const SampleBands bands = + computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); + if (contains(bands.navBrowse, x, y)) { + // Open the Browse modal; seed its pending pick from the loaded id so the current + // capture reads as pre-selected. + browsePendingId_ = selectedId_; + lastBrowseClickCard_ = -1; + view_ = View::kBrowse; + invalidate(); + return; + } + if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; } + if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only + + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); + + // Preview-trigger button: fire the loaded capture at its root through the voice engine + // (momentary — note-on on press, note-off on release). + if (contains(cr.preview, x, y)) { + const int note = effectiveRoot(); + if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); + previewingNote_ = note; + processor_->previewNoteOn(note); + invalidate(); + return; + } + // Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself + // never jumps the value (FA4); the delta from the grab point maps via knobDragValue. + if (contains(cr.velCell, x, y)) { + drag_ = DragKind::kDeckKnob; + dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) + dragParamZone_ = -1; + dragKnobStartValue_ = previewVelocity01(); + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + return; + } + // The mini curve-preview button: summon the popup editor. + if (contains(cr.curveBtn, x, y)) { + curvePopupOpen_ = true; + invalidate(); + return; + } + // Channel toggle. + if (contains(chan.mono, x, y)) { + channelMode_ = ChannelMode::Mono; + processor_->setChannelMode(ChannelMode::Mono); + invalidate(); + return; + } + if (contains(chan.stereo, x, y)) { + channelMode_ = ChannelMode::Stereo; + processor_->setChannelMode(ChannelMode::Stereo); + invalidate(); + return; + } + + // The knob deck (r11): toggles commit at once (a discrete, final edit — the slider + // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its + // clicks (no fall-through to the hero/markers). + if (contains(bands.deck, x, y)) { + const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y, + bands.deck.width); + const DeckHit hit = hitTestDeck(dl, x, y); + if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { + switch (static_cast(hit.id)) { + case ParamControl::kVoiceMode: { + // Processor-side per-instance param: live setter (engine rebuild via + // the drain-slot swap — tails survive), local snapshot in step. + const VoiceMode m = + (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly; + if (m != voiceMode_) { + voiceMode_ = m; + processor_->setVoiceMode(m); + } + invalidate(); + break; + } + case ParamControl::kMonoTrigger: { + if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly + const MonoTrigger t = + (hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; + if (t != monoTrigger_) { + monoTrigger_ = t; + processor_->setMonoTrigger(t); + } + invalidate(); + break; + } + default: { + // Zone-param toggles (play mode / pitch engine / pitch-env enable): + // materialize the one-zone site, apply, commit. + const int zi = ensureSampleZone(); + if (zi >= 0) { + applyZoneControl(zi, hit.id, 0.0, hit.segment); + selectedZone_ = zi; + commitAndReload(); + } + break; + } + } + return; + } + if (hit.kind == DeckHitKind::Knob) { + // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off. + const bool pitchEnvKnob = + hit.id == static_cast(ParamControl::kPitchEnvAttack) || + hit.id == static_cast(ParamControl::kPitchEnvDecay) || + hit.id == static_cast(ParamControl::kPitchEnvDepth); + if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return; + if (hit.id == static_cast(ParamControl::kVoiceCount) || + hit.id == static_cast(ParamControl::kMasterGain)) { + // Processor-side knobs: transient live writes, no map edit, no reload. + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = -1; + dragKnobStartValue_ = deckControlNorm(hit.id, probeZone); + } else { + // Zone-param knobs: live-drag the map, commit on release. + const int zi = ensureSampleZone(); + if (zi < 0) return; + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = zi; + selectedZone_ = zi; + dragStartMap_ = map_; + dragKnobStartValue_ = + deckControlNorm(hit.id, map_.zones[static_cast(zi)]); + } + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + } + return; + } + + // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = bands.hero; + if (frames > 0) { + const double rate = liveSampleRate(); + if (rate > 0.0) { + const PerformanceZone zone = effectiveSampleZone(); + const std::int64_t startFrame = zone.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); + const double totalSeconds = static_cast(frames) / rate; + const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y); + if (nh.hit) { + drag_ = DragKind::kEnvNode; + envNode_ = nh.node; + dragStartX_ = x; + dragStartY_ = y; + dragStartEnv_ = env; + dragSampleFrames_ = frames; + dragStartFrame_ = startFrame; + dragStartMap_ = map_; + return; // node moves once the cursor drags + } + } + const SetupMarkers m = pickedMarkers(frames); + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); + if (hit >= 0) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = static_cast(hit); + dragStartX_ = x; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; + dragStartMap_ = map_; + return; + } + } + + // Fenced root strip: grab the root marker (remainder-width since r11). + if (cr.rootStrip.width > 0) { + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + dragStartMap_ = map_; + onMouseMove(x, y); // apply the click as the first delta==0 set + return; + } + } + return; + } + + // ---- Zone surface (S-VIEW-8 / FB2): the face branch lives in editor_input_browse_zone ---- + mouseDownZone(w, h, x, y); +} + +void ReaSamplerEditor::onMouseMove(int x, int y) { + if (drag_ == DragKind::kNone) return; + dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn) + dragCurY_ = y; + RECT rc{}; + GetClientRect(childHwnd_, &rc); + const int w = rc.right - rc.left; + const int h = rc.bottom - rc.top; + const int dx = x - dragStartX_; + + if (drag_ == DragKind::kDeckKnob) { + // r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from + // the value at grab (up = increase), so the value tracks relative motion and never + // jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. + const int dy = y - dragStartY_; + applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); + invalidate(); + return; + } + + // r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted + // below the kDeckKnob early-return — that branch uses neither deckDescs nor bands. + const std::vector deckDescs = deckGroupDescs(effectiveSampleZone().play); + const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); + + if (drag_ == DragKind::kRootMarker) { + // The fenced root strip on the Sample cluster band. Setting the root materializes a + // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — + // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip; + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int note = resolveDragNote(sl, dragStartRoot_, dx); + bool found = false; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.rootOverride = note; + selectedZone_ = i; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.rootOverride = note; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + } + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + return; + } + + if (drag_ == DragKind::kEnvNode) { + // S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through + // the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the + // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_) + // so the delta is absolute. Materialize the zone if needed (mirror of the marker path). + const std::int64_t frames = dragSampleFrames_; + const double rate = liveSampleRate(); + if (frames <= 0 || rate <= 0.0) return; + const double totalSeconds = static_cast(frames) / rate; + const int dy = y - dragStartY_; + const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero, + totalSeconds, envClampBounds(), dx, dy); + const int zi = ensureSampleZone(); + if (zi >= 0) { + unpackEnvelope(edited, frames, dragStartFrame_, + map_.zones[static_cast(zi)].play); + selectedZone_ = zi; + } + invalidate(); // live feedback; commit on WM_LBUTTONUP + return; + } + + if (drag_ == DragKind::kCurveNode) { + // S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure + // inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + + // box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the + // commit lands on WM_LBUTTONUP. + if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast(map_.zones.size())) return; + if (curvePointIndex_ < 0) return; + const int dy = y - dragStartY_; + map_.zones[static_cast(dragCurveZone_)].velocityCurve = + VelocityCurve::resolvePointDrag(dragStartCurve_, + static_cast(curvePointIndex_), + curveBoxFromRect(dragCurveRect_), dx, dy); + invalidate(); + return; + } + + if (drag_ == DragKind::kWaveMarker) { + // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap + // it against the decoded PCM, apply the inter-marker clamps, and write the override live. + const Rect waveArea = bands.hero; + const std::int64_t frames = dragSampleFrames_; + if (frames <= 0) return; + + // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). + const int idx = static_cast(waveMarker_); + const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, + dragStartMarkers_.loopEnd}; + std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); + + // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware + // requirement). Pure over the cached mono frames — no host types, no file I/O. + const std::vector& pcm = monoPcmFor(selectedId_); + if (!pcm.empty()) { + newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), + newFrame); + } + + // Build the edited marker set from the snapshot, moving only the grabbed marker, then + // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. + SetupMarkers m = dragStartMarkers_; + if (waveMarker_ == WaveMarker::kStart) { + m.start = newFrame; + } else if (waveMarker_ == WaveMarker::kLoopStart) { + m.loopStart = (std::min)(newFrame, m.loopEnd); + m.hasLoop = true; + } else { // kLoopEnd + m.loopEnd = (std::max)(newFrame, m.loopStart); + m.hasLoop = true; + } + if (m.start < 0) m.start = 0; + if (m.start > frames - 1) m.start = frames - 1; + + // Upsert the override on the picked id (mirror of the root-marker path); commit lands on + // release, this is live feedback. Set selectedZone_ so the control panel stays visible + // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty + // map hides controls after the first marker drag on the single-capture face). + selectedZone_ = upsertPickedOverride(m); + invalidate(); + return; + } + + if (drag_ == DragKind::kScrollThumb) { + // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag + // only happens in the Browse modal (the sole card grid). The visible-card window recomputes + // at paint from scrollOffset_. + const int dyThumb = y - dragStartY_; + const BrowseModal bm = computeBrowseModal(w, h); + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), + dragStartScrollOffset_, dyThumb); + invalidate(); + return; + } + + // Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached + // in the Zone surface where selectedZone_ is set + the strip lives under its content area. + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const Rect stripArea = zonesStripArea(zoneContentArea(w, h)); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (drag_ == DragKind::kZoneLow) { + z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); + } else if (drag_ == DragKind::kZoneHigh) { + z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); + } else if (drag_ == DragKind::kZoneBody) { + // Move the whole span: apply the SAME delta to both edges so the span is preserved, + // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). + const int newLow = resolveDragNote(sl, dragStartLow_, dx); + const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); + const int span = dragStartHigh_ - dragStartLow_; + if (newLow < 0) { z.lowNote = 0; z.highNote = span; } + else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } + else { z.lowNote = newLow; z.highNote = newHigh; } + } + invalidate(); +} + +void ReaSamplerEditor::onMouseUp(int x, int y) { + // Release a held preview note first (the preview button is a momentary key: note-off on up). + // This runs regardless of drag state — the preview press does not start a drag. + if (previewingNote_ >= 0) { + if (processor_) processor_->previewNoteOff(previewingNote_); + previewingNote_ = -1; + invalidate(); + } + if (drag_ == DragKind::kNone) return; + const DragKind kind = drag_; + const int paramId = dragParamId_; + const int curveIdx = curvePointIndex_; + const int curveZone = dragCurveZone_; + const Rect curveRect = dragCurveRect_; + drag_ = DragKind::kNone; + dragParamId_ = -1; + dragParamZone_ = -1; + curvePointIndex_ = -1; + dragCurveZone_ = -1; + // A scrollbar drag is transient UI (no map change), and the processor-side knobs (the + // preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that + // don't reload the instrument via the map path. Master gain is an atomic the audio thread + // reads directly. Voice count: the label/needle tracks live during the drag but the engine + // rebuild (setVoiceCount) fires ONCE here on release — not per integer step. + const bool deckTransient = + kind == DragKind::kDeckKnob && + (paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || + paramId == static_cast(ParamControl::kMasterGain)); + if (kind == DragKind::kScrollThumb || deckTransient) { + // Commit the voice count now that the drag is complete (one rebuild per full drag). + if (deckTransient && processor_ && + paramId == static_cast(ParamControl::kVoiceCount)) + processor_->setVoiceCount(voiceCount_); + invalidate(); + return; + } + // S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the + // dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain + // move — its amp keeps the last clamped drag value). + if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 && + curveZone < static_cast(map_.zones.size())) { + const bool off = x < curveRect.x - kCurveDragOffMargin || + x > curveRect.right() + kCurveDragOffMargin || + y < curveRect.y - kCurveDragOffMargin || + y > curveRect.bottom() + kCurveDragOffMargin; + if (off) { + map_.zones[static_cast(curveZone)].velocityCurve.deletePoint( + static_cast(curveIdx)); + hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint + } + } + commitAndReload(); +} + +void ReaSamplerEditor::onMouseRDown(int x, int y) { + // r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete + // affordance; Alt-click and drag-off remain as landed alternates. Commits immediately + // through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint + // right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the + // Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) — + // and never during an in-flight left drag. + if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return; + if (drag_ != DragKind::kNone) return; + RECT rc{}; + GetClientRect(childHwnd_, &rc); + const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); + if (!contains(pl.curveBox, x, y)) return; + // Hit-test first (read-only, via popupZone) so a right-click that lands between nodes + // does not materialize an uncommitted zone in map_. Materialize only on an actual hit. + const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); + const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y); + if (idx < 0) return; + const int zi = popupZoneIndex(); + if (zi < 0) return; + PerformanceZone& z = map_.zones[static_cast(zi)]; + if (z.velocityCurve.deletePoint(static_cast(idx))) { + selectedZone_ = zi; + hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node + commitAndReload(); + } +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h new file mode 100644 index 0000000..438eadf --- /dev/null +++ b/src/shell/instrument/editor_internal.h @@ -0,0 +1,239 @@ +// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family +// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp). +// Included ONLY by the editor's own shell TUs (editor_session / editor_controls / +// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the +// former god-TU's anonymous-namespace helpers that more than one split TU needs: the +// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root +// marker / title band), the label helpers, the deck group ids, and the velocity-curve +// box derivation. All inline; behavior-identical to the pre-split definitions. + +#pragma once + +#include +#include +#include +#include + +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve::Box (curveBoxFromRect) +#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel) +#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type) + +#ifdef _WIN32 +#include "wdltypes.h" +#include "lice/lice.h" + +#include "core/audio/peaks.h" // Envelope (drawEnvelope) +#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins) +#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4) +#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip) +#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount +#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor +#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice +#endif + +namespace reasampler::vst { + +// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck +// order. Shared by the deck-desc builders (editor_controls) and the deck painter. +enum DeckGroup { + kGroupAmpEnv = 0, + kGroupPitch, + kGroupPitchEnv, + kGroupVoice, + kGroupMaster, +}; + +// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the +// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The +// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1 +// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box +// through. Drag-off: release beyond box+margin deletes the dragged node. +inline constexpr int kVelCurveInset = 14; +inline constexpr int kCurveDragOffMargin = 24; + +// The pure-module mapping Box for a drawn curve rect: inset from the border so node +// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add, +// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift. +inline instrument::engine::VelocityCurve::Box curveBoxFromRect( + const instrument::ui::Rect& r) { + return instrument::engine::VelocityCurve::Box{ + r.x + kVelCurveInset, r.y + kVelCurveInset, + (std::max)(0, r.width - 2 * kVelCurveInset), + (std::max)(0, r.height - 2 * kVelCurveInset)}; +} + +// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the +// common DAW convention REAPER uses). +inline std::string noteLabel(int note) { + static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + if (note < 0) note = 0; + if (note > 127) note = 127; + const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 + return std::string(kNames[note % 12]) + std::to_string(octave); +} + +// A display name for a bank sample id: the snapshotted bank list first, then the +// instance-OWNED ref's displayName (pS — the label survives with the extension absent / +// bank unreadable). "?" only when neither source knows the id. +inline std::string sampleLabel(const std::vector& samples, + const instrument::map::SampleRefs& refs, + const std::string& id) { + for (const instrument::map::SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + for (const instrument::map::SampleRefEntry& e : refs) { + if (e.sampleId == id && !e.displayName.empty()) return e.displayName; + } + return "?"; +} + +#ifdef _WIN32 + +// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- +// +// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against +// `KitBox` (component_geometry). This is the single boundary that bridges them so every +// draw routes through the L1 kit (theme roles + draw_kit). +inline ui::KitBox toKitBox(const instrument::ui::Rect& r) { + return ui::KitBox{r.x, r.y, r.width, r.height}; +} + +// Kit text in a palette ROLE (the common case). Left/Right/Center via Align. +inline void kitText(LICE_IBitmap* bmp, const instrument::ui::Rect& r, const char* s, + Font font, ui::Role role, Align align = Align::Left) { + text(bmp, toKitBox(r), s, font, role, align); +} + +inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r, + const char* s, Font font, ui::Role role) { + text(bmp, toKitBox(r), s, font, role, Align::Center); +} + +// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3). +inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r, + const audio::Envelope& env) { + drawWaveform(bmp, toKitBox(r), env); +} + +// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the +// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. +// thumbnailFor clamps the request to the decoded frame count. +inline int thumbBins(const instrument::ui::BrowserLayout& layout) { + return (std::max)(1, kWaveformOversample * + ui::waveformColumnCount(toKitBox( + instrument::ui::cardThumbnailRect(layout, 0)))); +} + +// Draw the title band with the live readout. Shared by the Sample face (nav visible) — +// Browse/Zone draw their own back button in place of the nav. +inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title, + const std::string& readout) { + fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest); + instrument::ui::Rect titleText = + instrument::ui::Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom()); + kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary); +} + +// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle +// map; this turns it into LICE calls through the kit's palette roles. LICE's arc +// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE +// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous +// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto +// -150..+150 degrees. One conversion, both arcs. +inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect, + double value01, ui::InteractionState st) { + using instrument::ui::KnobArc; + using instrument::ui::KnobGeometry; + using instrument::ui::KnobPoint; + const KnobGeometry kg = instrument::ui::computeKnob(knobRect); + if (kg.radius <= 1.0) return; + constexpr double kDegToRad = 3.14159265358979323846 / 180.0; + const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep + const float cx = static_cast(kg.centerX); + const float cy = static_cast(kg.centerY); + const float rOuter = static_cast(kg.radius) - 0.5f; + const bool disabled = (st == ui::InteractionState::Disabled); + const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover); + + // Face: a filled circle in the cell surface color under the interaction state. + LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)), + 1.0f, 0, true); + // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). + const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); + const float a1 = static_cast( + (arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); + LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0, + true); + // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / + // dragging, dim when disabled). + const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); + if (v > 0.0) { + const float av = static_cast( + (arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); + const ui::Role valueRole = disabled ? ui::Role::TextDim + : (hot ? ui::Role::AccentHot : ui::Role::AccentPrimary); + LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(ui::roleColor(valueRole)), 1.0f, 0, true); + } + // Needle: from ~35% radius out to the rim at the value's angle. + const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v); + const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); + const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); + const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary; + LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), + static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), + toLice(ui::roleColor(needleRole)), 1.0f, 0, true); +} + +// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature +// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave +// hairline ticks. Shared by the setup face + the Zones strip so both read as the same +// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not +// a keyboard shape) so pitch position reads as a keyboard at a glance. +inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) { + using instrument::ui::StripLayout; + if (stripArea.width <= 0 || stripArea.height <= 0) return; + const StripLayout sl = instrument::ui::layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; + const LICE_pixel darkKey = toLice(ui::roleColor(ui::Role::BgBase)); + for (int n = 0; n <= 127; ++n) { + const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); + const int x0 = k.x + sx; + const int x1 = + (n < 127) ? instrument::ui::keyRect(sl, n + 1).x + sx : stripArea.right(); + const int cw = (std::max)(1, x1 - x0); + const ui::KitColor hue = ui::spectralColor(static_cast(n) / 127.0); + LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); + if (!instrument::ui::isNaturalKey(n)) { + LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); + } + } + // Faint per-octave key ticks (hairline role) for orientation. + const LICE_pixel tick = toLice(ui::roleColor(ui::Role::LineHairline)); + for (int n = 0; n <= 127; n += 12) { + const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); + LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); + } +} + +// Draw the single-capture root marker on the strip: an accent-primary bar with a soft +// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. +inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea, + const instrument::ui::StripLayout& sl, int root) { + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; + const instrument::ui::Rect marker = instrument::ui::rootMarkerRect(sl, root); + const int mw = (std::max)(2, marker.width); + const LICE_pixel accent = toLice(ui::roleColor(ui::Role::AccentPrimary)); + const LICE_pixel glow = toLice(ui::roleColor(ui::Role::AccentHot)); + // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). + LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); + LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint_browse_zone.cpp b/src/shell/instrument/editor_paint_browse_zone.cpp new file mode 100644 index 0000000..2d99f7e --- /dev/null +++ b/src/shell/instrument/editor_paint_browse_zone.cpp @@ -0,0 +1,278 @@ +// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE +// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then- +// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar, +// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones +// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only +// (D5). Shares the Sample face's painters (title band / empty state / deck / curve +// button / popup) via the class + editor_internal.h. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2) +#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; // kit vocabulary +using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry +using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs + +void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { + // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying + // Sample face with a bg/base wash, then draw the picker opaque on top. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); + const BrowseModal bm = computeBrowseModal(w, h); + + // Title band + Back button (returns to Sample, discarding any pending pick). + drawTitleBand(bmp, bm.title, "Browse - pick a capture"); + { + const KitButtonBox box{toKitBox(bm.back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. + const Rect searchAbs = bm.search; + const InteractionState searchState = + searchFocused_ ? InteractionState::Focus + : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); + if (searchFocused_) { + LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1, + searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); + } + { + std::string sb = searchQuery_.empty() + ? std::string("Search captures...") + : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); + Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom()); + kitText(bmp, sbText, sb.c_str(), Font::Label, + searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); + } + + // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). + const Rect browserArea = bm.content; + const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height); + const int ox = browserArea.x; + const int oy = browserArea.y; + scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); + + const int tabCount = static_cast(banks_.size()) + 1; + for (int i = 0; i < tabCount; ++i) { + Rect t = filterTabRect(bl, tabCount, i); + t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy); + const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; + const bool active = (i == 0) ? activeFilterBankId_.empty() + : (banks_[static_cast(i - 1)].id == activeFilterBankId_); + const InteractionState state = + active ? InteractionState::Active + : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, toKitBox(t), Role::BgCell, state); + kitTextCentered(bmp, t, label.c_str(), Font::Label, + active ? Role::BgBase : Role::TextPrimary); + } + + // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) + // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. + const int bins = thumbBins(bl); + const int cardCount = static_cast(visible_.size()); + const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); + for (int i = vr.first; i < vr.last; ++i) { + Rect content = cardContentRect(bl, i); + Rect thumb = cardThumbnailRect(bl, i); + Rect labelR = cardLabelRect(bl, i); + content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_, + content.right() + ox, content.bottom() + oy - scrollOffset_); + thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_, + thumb.right() + ox, thumb.bottom() + oy - scrollOffset_); + labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_, + labelR.right() + ox, labelR.bottom() + oy - scrollOffset_); + + const SampleChoice& s = visible_[static_cast(i)]; + const bool pending = (s.id == browsePendingId_); + const bool loaded = (s.id == selectedId_); + const InteractionState cardState = + isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; + fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); + const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) + : (loaded ? roleColor(Role::AccentTertiary) + : roleColor(Role::LineHairline)); + LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1, + toLice(cardBorder), 1.0f, 0); + drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); + + std::string caption = s.displayName.empty() ? s.id : s.displayName; + Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2); + Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom()); + kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); + std::string badge; + if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); + else if (s.key) badge = *s.key; + else badge = "root -"; + kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); + } + + // Scrollbar thumb. + { + const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); + if (thumb.height > 0) { + const bool dragging = (drag_ == DragKind::kScrollThumb); + const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); + LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height, + toLice(tc), 0.8f, 0); + } + } + + if (visible_.empty()) paintEmptyState(bmp, browserArea); + + // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert + // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. + Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h); + fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); + { + const KitButtonBox box{toKitBox(bm.cancel)}; + const InteractionState st = + isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Cancel", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bm.confirm)}; + const bool armed = !browsePendingId_.empty(); + const InteractionState st = armed + ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) + : InteractionState::Rest; + drawButton(bmp, box, "Load", st, /*warn=*/false); + } +} + +void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { + // Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns + // to the Sample home on close. + const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h)); + drawTitleBand(bmp, title, "Zone - keyboard map"); + { + const Rect back = zoneBackRect(w, h); + const KitButtonBox box{toKitBox(back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + const Rect content = zoneContentArea(w, h); + const int pad = 8; + + // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip + // with one bar per zone. Delete is a small × on the selected zone (keystroke also). + Rect addR = zoneAddRect(content); + { + const KitButtonBox box{toKitBox(addR)}; + const InteractionState state = + isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false); + } + + Rect delR = zoneDeleteRect(addR); + if (selectedZone_ >= 0) { + const KitButtonBox box{toKitBox(delR)}; + const InteractionState state = + isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest; + // Deleting a zone is not a byte-destroying act (no file removed — the bank is + // read-only here), so it is a normal button, not `warn`. + drawButton(bmp, box, "Delete", state, /*warn=*/false); + } + + // The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per + // zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which + // zone is live"); the rest take the categorical secondary hue at low alpha. + const Rect stripArea = zonesStripArea(content); + drawSpectralStrip(bmp, stripArea); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); + const int bw = (std::max)(2, bar.width); + const bool sel = (i == selectedZone_); + if (sel) { + // Static glow halo behind the live zone, then the crisp accent-primary bar. + LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height, + toLice(roleColor(Role::AccentHot)), 0.30f, 0); + LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, + toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); + } else { + LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, + toLice(roleColor(Role::AccentSecondary)), 0.55f, 0); + } + } + + // A one-line legend of the selected zone below the strip, with three click-to-type numeric + // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it + // (entryField_) and typed text commits via parseNoteEntry on Enter. + const int legendTop = stripArea.bottom() + 8; + Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18); + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()), + sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{}, + z.sampleId) + .c_str(), + Font::Label, Role::TextPrimary); + // Three fields laid out left-to-right after the sample label. A focused field lifts to + // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter. + const Rect fields = noteEntryFieldsArea(content); + const char* names[3] = {"Low", "High", "Root"}; + const std::string vals[3] = { + noteLabel(z.lowNote), noteLabel(z.highNote), + z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; + for (int f = 0; f < 3; ++f) { + const Rect fr = noteEntryFieldRect(fields, f); + const bool editing = (entryField_ == f); + fillSurface(bmp, toKitBox(fr), Role::BgCell, + editing ? InteractionState::Focus : InteractionState::Rest); + const KitColor border = + editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1, + toLice(border), 1.0f, 0); + std::string cap = std::string(names[f]) + ": " + + (editing ? (entryText_ + "_") : vals[f]); + kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(), + Font::ValueMono, Role::TextPrimary); + } + } else if (map_.zones.empty()) { + kitText(bmp, infoR, + "No zones. Add Zone maps the picked capture across the keyboard.", + Font::Label, Role::TextDim); + } + + // The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck + + // curve-preview-button/popup grammar as the Sample face — one control language over the one + // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only + // the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live + // on the Sample deck only. + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play)); + paintCurveButton(bmp, zonesCurveButton(content), z); + } + + // The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST — + // the same modal grammar as the Sample face. + if (curvePopupOpen_) paintCurvePopup(bmp, w, h); +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_paint_sample.cpp b/src/shell/instrument/editor_paint_sample.cpp new file mode 100644 index 0000000..6194f9d --- /dev/null +++ b/src/shell/instrument/editor_paint_sample.cpp @@ -0,0 +1,518 @@ +// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of +// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title +// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the +// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup +// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only +// (D5); draws through the L1 kit by palette role. All layout math is pure +// (editor_geometry / knob_deck / curve_popup) — this TU only draws. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include +#include + +#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning) +#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) +#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize +#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers) +#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18) +#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …) +using namespace reasampler::instrument::ui; // pure geometry (bands / cluster / deck / popup / strip) +using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback) +using audio::computeEnvelope; + +namespace { +// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette: start = teal +// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple. +constexpr Role kRoleStartMarker = Role::AccentSecondary; +constexpr Role kRoleLoopMarker = Role::AccentTertiary; +} // namespace + +void ReaSamplerEditor::paint(HDC hdc) { + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + LICE_SysBitmap bmp(w, h); + LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); + + // S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn + // OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the + // modal reads as a sheet layered over the home face (the "picker over the document" grammar). + if (view_ == View::kZone) { + paintZone(&bmp, w, h); + } else { + paintSample(&bmp, w, h); + if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); + } + + // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. + // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn + // LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). + if (dropHintTicks_ > 0) { + const int bannerTop = (std::min)(kTitleHeight, h); + const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); + Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH); + // A transient notice, not the live layer — draw it on the accent-tertiary categorical + // hue with a dark label so it reads as "attention, not action". + fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest); + kitTextCentered(&bmp, banner, + "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.", + Font::Label, Role::BgBase); + } + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { + // r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP + // ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). + const PerformanceZone deckZone = effectiveSampleZone(); + const std::vector deckDescs = deckGroupDescs(deckZone.play); + const SampleBands bands = + computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); + + // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct + // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable + // signal. + std::string title = version::vstPluginName(); // channel-derived (S18) + if (processor_ && processor_->bridge().isConnected()) { + // The instance's OWN loaded state outranks bank availability (pS: the bank is a + // browser source, not the instrument's identity) — a self-contained instance names + // its sound (refs displayName fallback) even when the bank snapshot is empty. + if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; + else if (!selectedId_.empty()) + title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]"; + else if (samples_.empty()) title += " [bank empty]"; + else title += " [pick a capture]"; + } else { + title += " [host: no bridge]"; + } + drawTitleBand(bmp, bands.title, title); + + // Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap + // surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw + // it Active (accent-primary) so it reads as "start here". + const bool empty = selectedId_.empty() && map_.zones.empty(); + { + const KitButtonBox box{toKitBox(bands.navBrowse)}; + const InteractionState st = empty ? InteractionState::Active + : (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest); + drawButton(bmp, box, "Browse", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bands.navZone)}; + const InteractionState st = + isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Zone", st, /*warn=*/false); + } + + // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing + // at Browse (which is lit above). No hero waveform / controls to draw. + if (empty) { + Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom()); + paintEmptyState(bmp, body); + return; + } + + // Resolve the effective single-capture zone: the picked id's one-zone override when present, + // else the product-default play params (S15-F2 — the single capture is a one-zone map). This + // is the ONE storage site both Sample and Zone edit. + const PerformanceZone& zone = deckZone; + + // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = bands.hero; + fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); + if (frames > 0 && waveArea.width > 0) { + // FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this + // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact + // partition — extra bins produce no visible change. Clamped to frame count below. + const std::int64_t wantBins = + static_cast((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) * + kWaveformOversample; + const std::size_t bins = + static_cast(wantBins < frames ? wantBins : frames); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins); + drawEnvelope(bmp, waveArea, env); + + const SetupMarkers m = pickedMarkers(frames); + if (m.hasLoop && m.loopEnd > m.loopStart) { + const int lx = frameToX(waveArea, frames, m.loopStart); + const int rx = frameToX(waveArea, frames, m.loopEnd); + if (rx > lx) { + LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height, + toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); + } + } + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; + for (int i = 0; i < 3; ++i) { + const int mx = frameToX(waveArea, frames, markerFrames[i]); + const bool loopMarker = (i != 0); + const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; + LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height, + toLice(roleColor(markerRoles[i])), alpha, 0); + } + + // S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero. + paintEnvelopeOverlay(bmp, waveArea, zone, frames); + } else { + kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); + } + + // --- Root + preview cluster (r11: remainder-width root strip, preview button, radial + // velocity knob, mini curve-preview button, channel toggle) ----------------------------- + fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); + int root = effectiveRoot(); + if (cr.rootStrip.width > 0) { + drawSpectralStrip(bmp, cr.rootStrip); + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + drawRootMarker(bmp, cr.rootStrip, sl, root); + } + + // Preview-trigger button (fires the loaded capture at root through the live voice engine). + { + const KitButtonBox box{toKitBox(cr.preview)}; + const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active + : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); + drawButton(bmp, box, "Preview", st, /*warn=*/false); + } + // Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same + // persisted previewVelocity seam. Label swaps to the live value during hover/drag. + { + const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2); + const bool hov = isHovered(HoverKind::kVelKnob, -1); + const InteractionState st = dragging ? InteractionState::Dragging + : (hov ? InteractionState::Hover + : InteractionState::Rest); + drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st); + if (dragging || hov) { + char buf[8]; + snprintf(buf, sizeof(buf), "%d", + static_cast(previewVelocity01() * 127.0 + 0.5)); + kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim); + } else { + kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim); + } + } + // The mini curve-preview button (r11): opens the popup editor. Shared painter with the + // Zone panel's button (FB2 — one grammar on both surfaces). + paintCurveButton(bmp, cr.curveBtn, zone); + // Mono | Stereo output-mode toggle. + { + const bool isStereo = (channelMode_ == ChannelMode::Stereo); + const InteractionState monoState = !isStereo ? InteractionState::Active + : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest); + const InteractionState stereoState = isStereo ? InteractionState::Active + : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest); + fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState); + fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState); + kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary); + kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); + } + + // --- The knob deck (r11: the fenced control groups, bottom-anchored) ------------------- + paintKnobDeck(bmp, bands.deck, zone, deckDescs); + + // --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ---- + if (curvePopupOpen_) paintCurvePopup(bmp, w, h); +} + +void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, + const PerformanceZone& zone, std::int64_t frames) { + if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return; + const double rate = liveSampleRate(); + if (rate <= 0.0) return; + const double totalSeconds = static_cast(frames) / rate; + const std::int64_t startFrame = zone.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); + const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); + + // Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct + // curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right). + const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); + for (std::size_t i = 1; i < poly.size(); ++i) { + const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x)); + const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x)); + LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); + } + // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- + // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is + // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd + // at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the + // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. + const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); + for (const EnvVertex& v : poly) { + if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; + const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); + const int r = 3; + const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x)); + const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y)); + LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); + } +} + +void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, + const PerformanceZone& zone) { + if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect) + + // The bordered box: a panel surface + hairline border, drawn by palette role. No corner + // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the + // popup is the only host). + fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + + const VelocityCurve::Box box = curveBoxFromRect(r); + if (box.width <= 0 || box.height <= 1) return; + const VelocityCurve& curve = zone.velocityCurve; + + // Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical + // secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity + // and amp -> y mappings both go through the pure module so the trace, the node handles, and + // the hit-test all share one coordinate system. + const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); + int prevX = 0, prevY = 0; + for (int px = 0; px <= box.width; ++px) { + const int cx = box.left + px; + const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity; + const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y; + if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); + prevX = cx; + prevY = cy; + } + + // Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted + // to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor + // has passed kCurveDragOffMargin outside the box — release will delete the node). + const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); + const LICE_pixel handleWarn = toLice(roleColor(Role::Warn)); + // Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin? + const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x && + dragCurveRect_.y == r.y) && + (dragCurX_ < r.x - kCurveDragOffMargin || + dragCurX_ > r.right() + kCurveDragOffMargin || + dragCurY_ < r.y - kCurveDragOffMargin || + dragCurY_ > r.bottom() + kCurveDragOffMargin); + for (std::size_t i = 0; i < curve.points().size(); ++i) { + const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]); + const bool grabbed = (drag_ == DragKind::kCurveNode && + curvePointIndex_ == static_cast(i)); + const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(i)); + // A grabbed node in drag-off territory draws warn to signal "release will delete." + const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn + : (hot ? handleHot : handle); + const int nr = 3; + LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0); + } +} + +void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, + const PerformanceZone& zone, + const std::vector& descs) { + if (deckArea.width <= 0 || deckArea.height <= 0) return; + const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width); + const ZonePlaySeconds& play = zone.play; + const bool isMono = (voiceMode_ == VoiceMode::Mono); + const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); + + // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled + // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. + const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, + bool seg1Active, bool disabled) { + const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); + const InteractionState st0 = + disabled ? InteractionState::Disabled + : (!seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + const InteractionState st1 = + disabled ? InteractionState::Disabled + : (seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); + fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); + kitTextCentered(bmp, t.seg0, s0, Font::Micro, + disabled ? Role::TextDim + : (!seg1Active ? Role::BgBase : Role::TextPrimary)); + kitTextCentered(bmp, t.seg1, s1, Font::Micro, + disabled ? Role::TextDim + : (seg1Active ? Role::BgBase : Role::TextPrimary)); + }; + + // The knob's short name label (swapped for the live value during hover/drag — r11: no + // third line, no permanent value clutter). + const auto knobName = [](ParamControl c) -> const char* { + switch (c) { + case ParamControl::kAttack: return "Attack"; + case ParamControl::kHold: return "Hold"; + case ParamControl::kDecay: return "Decay"; + case ParamControl::kSustain: return "Sustain"; + case ParamControl::kRelease: return "Release"; + case ParamControl::kTrigFadeIn: return "Fade In"; + case ParamControl::kTrigLength: return "Len %"; + case ParamControl::kTrigFadeOut: return "Fade Out"; + case ParamControl::kKeyTrack: return "Key Trk"; + case ParamControl::kPitchEnvAttack: return "P.Att"; + case ParamControl::kPitchEnvDecay: return "P.Dec"; + case ParamControl::kPitchEnvDepth: return "P.Depth"; + case ParamControl::kVoiceCount: return "Voices"; + case ParamControl::kMasterGain: return "Gain"; + default: return ""; + } + }; + + for (const DeckGroupLayout& g : dl.groups) { + // The fence: a bg/panel box with a hairline border, caption micro-caps left. + fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1, + hairline, 1.0f, 0); + const char* caption = ""; + switch (g.id) { + case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; + case kGroupPitch: caption = "PITCH"; break; + case kGroupPitchEnv: caption = "PITCH ENV"; break; + case kGroupVoice: caption = "VOICE"; break; + case kGroupMaster: caption = "MASTER"; break; + default: break; + } + kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); + + // The compact caption toggle (r11: right-anchored IN the caption row, never full-width). + if (g.captionToggle.id >= 0) { + switch (static_cast(g.captionToggle.id)) { + case ParamControl::kPlayMode: + drawToggle(g.captionToggle, "Gate", "Trigger", + play.playMode == PlayMode::Trigger, false); + break; + case ParamControl::kPitchEngine: + drawToggle(g.captionToggle, "Varisp", "Presrv", + play.pitchEngine == PitchEngine::Preserve, false); + break; + case ParamControl::kPitchEnvEnable: + drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); + break; + case ParamControl::kVoiceMode: + drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); + break; + default: break; + } + } + // The row toggle (VOICE group's Retrig|Legato) — live only in Mono. + if (g.rowToggle.id >= 0) { + drawToggle(g.rowToggle, "Retrig", "Legato", + monoTrigger_ == MonoTrigger::Legato, !isMono); + } + + // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — + // stable geometry (r11). + for (const DeckCellLayout& c : g.cells) { + if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) + const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); + const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); + const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); + const InteractionState st = + disabled ? InteractionState::Disabled + : (dragging ? InteractionState::Dragging + : (hov ? InteractionState::Hover : InteractionState::Rest)); + drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st); + const std::string label = (dragging || hov) + ? deckValueLabel(c.id, zone) + : std::string(knobName(static_cast(c.id))); + kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); + } + } +} + +void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, + const PerformanceZone& zone) { + if (r.width <= 0 || r.height <= 0) return; + // The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone + // panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced + // in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE + // (accent-primary border) while its popup is open, and re-renders live as the popup + // edits the curve (same zone, re-read each paint). + const bool hov = isHovered(HoverKind::kCurveButton, -1); + fillSurface(bmp, toKitBox(r), Role::BgCell, + hov ? InteractionState::Hover : InteractionState::Rest); + const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) + : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0); + const VelocityCurve& curve = zone.velocityCurve; + const int inset = 3; + const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset, + r.height - 2 * inset}; + if (mini.width > 1 && mini.height > 1) { + const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); + int prevX = 0, prevY = 0; + for (int px = 0; px <= mini.width; ++px) { + const int mx = mini.left + px; + const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity; + const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y; + if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true); + prevX = mx; + prevY = my; + } + } +} + +void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { + // The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the + // Sample face stays legible behind it), then the centered sheet. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0); + const CurvePopupLayout pl = computeCurvePopup(w, h); + fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1, + pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); + { + const KitButtonBox box{toKitBox(pl.close)}; + const InteractionState st = isHovered(HoverKind::kPopupClose, -1) + ? InteractionState::Hover + : InteractionState::Rest; + drawButton(bmp, box, "x", st, /*warn=*/false); + } + // The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so + // trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() — + // the picked capture's one-zone site on the Sample face, the selected zone on the Zone + // surface (FB2). + paintVelocityCurve(bmp, pl.curveBox, popupZone()); +} + +void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { + // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from + // a bank filter that hides everything. Either way it is the "pick a capture" empty state. + const char* msg = samples_.empty() + ? "No captures in this project yet - capture audio into the bank to play it here." + : "No captures in this bank filter. Choose another bank tab above."; + // Split the area so the primary line sits centered and the S13 ingest affordance sits just + // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept + // discoverable here regardless of whether a drop ever lands on THIS window. + Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2); + Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom()); + kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim); + kitTextCentered(bmp, hint, + "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", + Font::Micro, Role::TextDim); +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp new file mode 100644 index 0000000..956f405 --- /dev/null +++ b/src/shell/instrument/editor_platform.cpp @@ -0,0 +1,271 @@ +// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v +// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child +// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch +// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the +// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles +// elsewhere). + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM +#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept +#endif + +#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only) +#include "shell/instrument/reasampler_processor.h" + +using namespace Steinberg; + +namespace reasampler::vst { + +#ifdef _WIN32 +namespace { +constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; + +// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency +// UI-thread timer: responsive enough that a recapture/ingest/assign refreshes "within a +// bounded cadence" (the S9 verify criterion) yet cheap — three small ext-state reads per +// tick, coalescing many bumps between ticks into one reload. 500 ms is a deliberate +// build-time residual. The id is a per-window SetTimer id (any nonzero). +constexpr UINT_PTR kSyncTimerId = 1; +constexpr UINT kSyncTimerIntervalMs = 500; +} // namespace +#endif + +tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { +#ifdef _WIN32 + if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; +#endif + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerEditor::canResize() { + return kResultTrue; +} + +tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { + // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before + // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the + // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title + // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully + // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is + // above this floor. + constexpr int kMinW = 560; + constexpr int kMinH = 460; + if (!rect) return kResultFalse; + if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; + if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; + return kResultTrue; +} + +#ifdef _WIN32 + +void ReaSamplerEditor::invalidate() { + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +} + +void ReaSamplerEditor::attachedToParent() { + HWND parent = static_cast(systemWindow); + if (!parent) return; + + HINSTANCE hInst = + reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); + if (!hInst) hInst = GetModuleHandle(nullptr); + + static bool classRegistered = false; + if (!classRegistered) { + WNDCLASSW wc{}; + wc.lpfnWndProc = &ReaSamplerEditor::wndProc; + wc.hInstance = hInst; + wc.lpszClassName = kChildClassName; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.style = CS_HREDRAW | CS_VREDRAW; + RegisterClassW(&wc); + classRegistered = true; + } + + // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a + // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on + // editor close: the embed strip in the SAME binary shares the kit's process-global font + // set, so a per-view shutdown could free fonts still in use by the other view. The tiny + // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. + kitFontsInit(); + + refreshFromBank(); + + const ViewRect& r = getRect(); + childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); + if (childHwnd_) { + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); + // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT + // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show + // the "drop on the panel" affordance instead of the OS bouncing the drop silently. + DragAcceptFiles(childHwnd_, TRUE); + // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's + // lifetime — created here, killed in removedFromParent — so an instance whose editor + // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). + SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); + // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor + // was closed) or a bank change applies the instant the editor opens, rather than waiting + // up to one timer interval. refreshFromBank above already primed the view; this folds in + // any pending assign/generation so the just-opened editor shows the assigned capture. + onSyncTimer(); + } +} + +void ReaSamplerEditor::removedFromParent() { + if (childHwnd_) { + KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +} + +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + tresult res = CPluginView::onSize(newSize); + if (childHwnd_ && newSize) { + MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them + } + return res; +} + +LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, + LPARAM lParam) { + auto* self = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps{}; + HDC hdc = BeginPaint(hwnd, &ps); + if (self) self->paint(hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: + if (self) { + SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) + self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + } + return 0; + case WM_MOUSEMOVE: + if (self) { + const int mx = GET_X_LPARAM(lParam); + const int my = GET_Y_LPARAM(lParam); + // Hover feedback (Phase L, L3): resolve the element under the pointer and + // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover + // clears when the pointer leaves the child (TrackMouseEvent is one-shot). + if (!self->mouseTracking_) { + TRACKMOUSEEVENT tme{}; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hwnd; + TrackMouseEvent(&tme); + self->mouseTracking_ = true; + } + // While a drag is in flight the drag owns the surface; skip hover resolution + // (a hover repaint mid-drag would fight the live drag feedback). + if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my); + self->onMouseMove(mx, my); + } + return 0; + case WM_MOUSELEAVE: + if (self) { + self->mouseTracking_ = false; + if (self->hover_.kind != HoverKind::kNone) { + self->hover_ = HoverTarget{}; + self->invalidate(); + } + } + return 0; + case WM_MOUSEWHEEL: + // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + case WM_CHAR: + // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + if (self) self->onSearchChar(static_cast(wParam)); + return 0; + case WM_GETDLGCODE: + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + return DLGC_WANTCHARS | DLGC_WANTARROWS; + case WM_LBUTTONUP: + if (self) { + self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + ReleaseCapture(); + } + return 0; + case WM_RBUTTONDOWN: + // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). + // Routed explicitly (the child wndproc historically handled only left-button). + if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_RBUTTONUP: + return 0; // claimed so the pair never reaches DefWindowProc (no context menu) + case WM_CAPTURECHANGED: + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its + // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset + // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. + if (self) { + // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture + // loss otherwise leaves the momentary-key voice hung with no note-off. + if (self->previewingNote_ >= 0) { + if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); + self->previewingNote_ = -1; + self->invalidate(); + } + if (self->drag_ != DragKind::kNone) { + // A scrollbar drag + the processor-side deck knobs (preview velocity -2 / + // voice count / master gain) are transient (no map mutation; dragStartMap_ + // not snapshotted) — reset drag state only, never touch map_. Every + // map-editing drag rolls its live mutation back to the snapshot. + const bool transient = self->drag_ == DragKind::kScrollThumb || + (self->drag_ == DragKind::kDeckKnob && + (self->dragParamId_ == -2 || + self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || + self->dragParamId_ == static_cast(ParamControl::kMasterGain))); + if (!transient) self->map_ = self->dragStartMap_; + self->drag_ = DragKind::kNone; + self->dragParamId_ = -1; + self->dragParamZone_ = -1; + self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) + self->dragCurveZone_ = -1; + self->invalidate(); + } + } + return 0; + case WM_DROPFILES: { + // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT + // read/ingest the paths (the instrument never ingests — the relay to the extension is + // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. + HDROP drop = reinterpret_cast(wParam); + const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + DragFinish(drop); + if (self) self->onFilesDropped(static_cast(count)); + return 0; + } + case WM_TIMER: + if (self && wParam == kSyncTimerId) self->onSyncTimer(); + return 0; + case WM_ERASEBKGND: + return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +#else // non-Windows: not a build target (D5), but keep the TU compilable. + +void ReaSamplerEditor::attachedToParent() {} +void ReaSamplerEditor::removedFromParent() {} +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + return CPluginView::onSize(newSize); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp new file mode 100644 index 0000000..a7a18e2 --- /dev/null +++ b/src/shell/instrument/editor_session.cpp @@ -0,0 +1,371 @@ +// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of +// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank / +// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading, +// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak +// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey — +// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the +// processor's reloadInstrument. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include +#include +#include + +#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail) +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames +#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "ext_keys.h" +#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter) +#include "shell/instrument/reaper_bridge.h" +#include "shell/instrument/reasampler_processor.h" + +using namespace Steinberg; + +namespace reasampler::vst { + +using namespace reasampler::instrument::map; // sample_map vocabulary (selectSample / listSamples / …) +using audio::computeEnvelope; +using capture::WavLayout; +using capture::extractFloatFrames; +using capture::parseWavLayout; +using capture::resolveBankFile; +using instrument::ui::nameMatchesQuery; +using ui::ThumbnailKey; +using ui::thumbnailKeyString; +using util::readFileBytes; + +ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) + : CPluginView(nullptr), processor_(processor) { + // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample + // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate + // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll + // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns + // read comfortably. + ViewRect r(0, 0, 840, 620); + setRect(r); +} + +void ReaSamplerEditor::refreshFromBank() { + // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks + pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) + if (!processor_) { + samples_.clear(); + banks_.clear(); + visible_.clear(); + selectedId_.clear(); + map_.zones.clear(); + selectedZone_ = -1; + return; + } + auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; + banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; + selectedId_ = processor_->selectedSampleId(); + const auto prevZoneCount = static_cast(map_.zones.size()); + map_ = processor_->performanceMap(); + channelMode_ = processor_->channelMode(); + voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot + voiceMode_ = processor_->voiceMode(); + monoTrigger_ = processor_->monoTrigger(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the + // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible + // modal would swallow clicks. + if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; + // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list + // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list + // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). + if (view_ == View::kZone && curvePopupOpen_) { + const auto newZoneCount = static_cast(map_.zones.size()); + if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; + } + // Drop a filter that names a bank no longer present. + if (!activeFilterBankId_.empty()) { + bool found = false; + for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; + if (!found) activeFilterBankId_.clear(); + } + rebuildVisible(); +} + +void ReaSamplerEditor::rebuildVisible() { + // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search + // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). + visible_.clear(); + for (const SampleChoice& s : samples_) { + const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; + if (!inBank) continue; + const std::string& name = s.displayName.empty() ? s.id : s.displayName; + if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); + } + // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel + // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it + // must not reset the user's scroll here. +} + +#ifdef _WIN32 +// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared +// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes +// Windows the only build target, but the TU must still compile elsewhere). +void ReaSamplerEditor::onSyncTimer() { + // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the + // processor (off the audio thread — the poll itself never touches process()). NEVER while a + // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the + // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. + if (!processor_) return; + if (drag_ != DragKind::kNone) return; // defer past the in-flight edit + + // An open editor marks THIS instance the focused assignment target (the thundering-herd + // policy — only an editor-open instance applies a pending assign; see the handoff). Pass + // true so this instance consumes the request; instances with no editor open do not poll at + // all (the timer is bound to the child window), so they never contend for the request. + const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); + + // Re-snapshot the editor's own view only when something changed (a reload from a bank + // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the + // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM + // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. + if (r.reloaded || r.applied) { + refreshFromBank(); + invalidate(); + } + + // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. + if (dropHintTicks_ > 0) { + --dropHintTicks_; + invalidate(); + } +} +#endif // _WIN32 + +void ReaSamplerEditor::commitAndReload() { + // UI thread only. Publish the edited selection + zones to the processor, then rebuild + // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). + // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank + // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the + // moment the instance becomes self-contained for that sample. + if (!processor_) return; + processor_->setSelectedSampleId(selectedId_); + processor_->setPerformanceMap(map_); + processor_->reloadInstrument(); + // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's + // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode + // the engine actually decoded with. + channelMode_ = processor_->channelMode(); +#ifdef _WIN32 + invalidate(); +#endif +} + +void ReaSamplerEditor::loadSelection(const std::string& id) { + // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous + // sample's materialized full-range zone must not linger — first-match resolve would + // keep playing it while the editor draws the new pick's zone (matched by sampleId, + // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. + selectedId_ = id; + if (reconcileSingleCaptureZones(map_, selectedId_)) { + selectedZone_ = map_.zones.empty() ? -1 : 0; + } + commitAndReload(); +} + +ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + SetupMarkers m; + // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic + // from the live bank blob (the same path selectSample uses); when that is not readable + // (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics + // (pS fallback). The override lives in map_. + if (processor_) { + std::optional sel; + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) sel = selectSample(*banksJson, selectedId_); + if (!sel) { + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; + } + if (sel && sel->loop.hasLoop) { + m.hasLoop = true; + m.loopStart = sel->loop.start; + m.loopEnd = sel->loop.end; + } + } + // The override (loop + start) on a zone for the picked id supersedes the intrinsic. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId != selectedId_) continue; + if (z.loopOverride) { + m.hasLoop = z.loopOverride->hasLoop; + m.loopStart = z.loopOverride->start; + m.loopEnd = z.loopOverride->end; + } + if (z.startPoint) m.start = *z.startPoint; + break; + } + // Default an unset loop's end to the sample length so the loop markers have somewhere sane + // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; + // the markers are still drawn (drag one to CREATE a loop). + if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + return m; +} + +int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { + // Find-or-append the zone for selectedId_ and write the loop/start override fields. + // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must + // be non-empty; callers are responsible for that guard. + // Returns the zone index (0-based) so callers can update selectedZone_. + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + return i; + } + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; +} + +PerformanceZone ReaSamplerEditor::effectiveSampleZone() const { + // The picked id's one-zone override, if the map already carries one; else a product-default + // zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes + // it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) return z; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + return z; +} + +int ReaSamplerEditor::effectiveRoot() const { + int root = 60; + for (const SampleChoice& s : samples_) { + if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; + } + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; + } + return root; +} + +int ReaSamplerEditor::ensureSampleZone() { + if (selectedId_.empty()) return -1; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; +} + +void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, + // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays + // identically to the un-zoned single capture (one chromatic zone) and round-trips through + // the component state; the zone becomes visible if the user opens the Zones panel. The bank + // intrinsic is NEVER written (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + upsertPickedOverride(m); + commitAndReload(); +} + +const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { + auto it = pcmCache_.find(sampleId); + if (it != pcmCache_.end()) return it->second; + + // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // resolve the path from the live bank blob (selectSample) and decode via the shared WAV + // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY + // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- + // independent) — the thumbnail bins this at whatever width, the snap scans it directly. + std::string relativePath; + std::vector mono; + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; + } + if (relativePath.empty()) { + // pS fallback: the bank blob is not readable (extension absent / not yet parsed) + // or the id went stale there — the instance-OWNED ref still carries the path, so + // a self-contained instance draws its loaded sound's waveform regardless. + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, sampleId)) { + relativePath = r->relativePath; + } + } + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + mono = downmixToMono(interleaved, layout.channelCount); + } + } + } + auto ins = pcmCache_.emplace(sampleId, std::move(mono)); + return ins.first->second; +} + +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + // T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former + // ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key + // grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale + // clear() on refresh/resize, so the bank generation carries no information here — 0. + const std::string key = + thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0}); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by + // every thumbnail width AND the S11 waveform surface + snap. + const std::vector& mono = monoPcmFor(sampleId); + Envelope env; + if (!mono.empty()) { + // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with + // trailing empty {0,0} bins, which would render a very short sample as a comb of + // spikes over flat gaps. + const std::size_t bins = + (std::min)(static_cast((std::max)(1, binCount)), mono.size()); + env = computeEnvelope(mono, 1, mono.size(), bins); + } + auto ins = thumbCache_.emplace(key, std::move(env)); + return ins.first->second; +} + +ReaSamplerEditor::~ReaSamplerEditor() { +#ifdef _WIN32 + if (childHwnd_) { + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +#endif +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp new file mode 100644 index 0000000..d1fef2f --- /dev/null +++ b/src/shell/instrument/processor_reload.cpp @@ -0,0 +1,489 @@ +// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument +// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap +// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param +// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8 +// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp +// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle +// TU) only touches the atomics this family publishes; the atomic-pointer-swap +// pattern deliberately gains NO virtual seam (T4-29). + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision +#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) +#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) +#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey + +namespace reasampler::vst { + +using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives +using namespace reasampler::wire; // assignment_request + sample_usage wire records + +namespace { + +// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is +// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather +// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice +// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the +// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays +// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. +constexpr std::size_t kPreserveVoiceCap = 8; + +// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy +// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the +// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic +// strength) is the requirement: two instances sharing a key is the copy-collision +// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision +// vanishingly unlikely. Off-thread only. +std::string mintUsageInstanceGuid() { + std::random_device rd; + std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); + std::uniform_int_distribution dist; + char buf[33] = {0}; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + static_cast(dist(gen)), + static_cast(dist(gen))); + return std::string(buf); +} + +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). +// Off-thread only (blocking file I/O). Empty on any failure — the caller treats +// an unreadable WAV as "nothing to play". + +// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file +// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode +// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for +// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path +// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no +// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by +// the zoned build and the single-capture path so both decode identically for the active mode. +std::optional decodeRelative(const std::string& projectDir, + const std::string& relativePath, + ChannelMode mode) { + const std::string abs = resolveBankFile(projectDir, relativePath); + if (abs.empty()) return std::nullopt; + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid) return std::nullopt; + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, + static_cast(layout.sampleRate)); + if (out.monoFrames.empty()) return std::nullopt; + return out; +} + +} // namespace + +std::string ReaSamplerProcessor::reloadInstrument() { + // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so + // the retired-slot free is single-writer. This mutex is NEVER taken on the audio + // thread — process() only touches the atomic. + std::lock_guard lock(reloadMutex_); + + // Mint this reload's generation number first so we can stamp the built instrument + // with it before publishing. Under reloadMutex_ no other reload races here. + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + + // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of + // truth for what to decode. The live bank blob, WHEN readable, is folded into the + // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in + // mechanism and the S9 recapture sync in one — but its absence changes NOTHING + // below: a project restored before the extension's PROJEXTSTATE parses (or with + // the extension absent entirely) resolves + plays from the persisted refs. The + // project dir comes from REAPER itself (EnumProjects), not from the extension. + const std::string selId = selectedSampleId(); + const PerformanceMap map = performanceMap(); + const std::vector ids = referencedSampleIds(selId, map); + SampleRefs refs; + { + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + std::lock_guard rl(refsMutex_); + if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); + // The LOAD path never prunes the owned table: dropping entries here on a transient + // bank miss could destroy the owned intrinsics of the previous selection — the ONE + // copy that survives with the extension absent. Entries for de-referenced ids stay + // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, + // where getState filters its snapshot via retainRefs to what the instance plays. + refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) + } + const std::string projectDir = bridge_.activeProjectDir(); + // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). + // Read once under its mutex, off the audio thread, before the decode loop. The single- + // capture branch below may auto-default it (GA) before its decode. + ChannelMode mode = channelMode(); + // Phase S: snapshot the voice-system parameters once — they are baked into the built + // engine's construction (the engine's config is immutable; a later change rebuilds). + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + std::string resolvedId; + std::unique_ptr built; + Keymap km; + bool haveKeymap = false; + + // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its + // zones against the OWNED refs (an id with no ref drops cleanly), decode each + // zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays + // its sample repitched from its effective root note (override > ref intrinsic > + // C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped + // (not the whole map): the defined no-play, no crash, no retry loop. + if (!map.empty()) { + const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map); + if (!resolved.zones.empty()) { + std::vector decoded; + std::vector kept; + decoded.reserve(resolved.zones.size()); + kept.reserve(resolved.zones.size()); + for (const ResolvedZone& rz : resolved.zones) { + std::optional pcm = + decodeRelative(projectDir, rz.relativePath, mode); + if (!pcm) continue; // unreadable/missing WAV -> drop this zone + kept.push_back(rz); + decoded.push_back(std::move(*pcm)); + } + km = buildZonedKeymap(kept, decoded); + haveKeymap = !km.zones.empty(); + } + } + + // 3. Single-capture fast path (S10): an empty performance map plays the ONE + // deliberately-selected capture chromatically across the whole keyboard, resolved + // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a + // selection with no ref) resolves to nothing, so an un-picked instrument stays + // SILENT (the editor shows its "pick a capture" empty state) rather than + // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). + if (!haveKeymap) { + if (const SelectedSample* sel = findRef(refs, selId)) { + // GA auto-default: channelModeFor computes the mode from the loaded capture's + // REQUESTED channel count (always 2 for extension captures; mono only for + // ingest-imported mono files). An unknown count (0) or explicit user choice + // returns the current mode unchanged. Decode-only: the output bus is fixed + // stereo, so no bus work follows a flip. + { + std::lock_guard cm(channelModeMutex_); + channelMode_ = channelModeFor(sel->channelCount, channelMode_, + channelModeExplicit_); + mode = channelMode_; + } + std::optional pcm = + decodeRelative(projectDir, sel->relativePath, mode); + if (pcm) { + km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, + sel->rootNote, sel->loop, + std::move(pcm->framesR)); + haveKeymap = true; + resolvedId = selId; // the concrete pick that resolved + } + } + } + + if (haveKeymap) { + // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). + // Every voice's shifter is pre-sized to this off-thread here, so process()-time + // note-on never allocates. Floored at 2 so a valid window is always a real ring + // (which also covers a pathological host rate <= 0 — no rate literal needed). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + } + + // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the + // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — + // a reload never cuts a sounding note; the next note-on plays the new state. The + // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard + // (process may still be mid-block reading it). A null `built` (no ref / unreadable + // WAV) installs silence while the displaced tails still ring out via the drain. + // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted + // pointer is re-owned by the graveyard. + publishBuiltLocked(std::move(built)); + + // 5. pS-usage: publish this instance's held captures so the extension's prune can + // never reclaim them (see publishUsage). AFTER the instrument swap, still off the + // audio thread and under reloadMutex_. Publishes regardless of decode success: + // the holds are the refs the instance RETAINS (its play-set), not what decoded — + // a transiently unreadable WAV must stay protected. + publishUsage(refs, ids); + return resolvedId; +} + +void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, + const std::vector& ids) { + if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do + + UsageRecord mine; + mine.trackGuid = bridge_.currentTrackGuid(); + for (const std::string& id : ids) { + if (const SelectedSample* ref = findRef(refs, id)) { + if (!ref->relativePath.empty()) { + mine.holds.push_back(UsageHold{id, ref->relativePath}); + } + } + } + + std::lock_guard lock(usageMutex_); + // A never-published instance with nothing held writes nothing — no key litter for + // fresh/empty instances. Once an identity exists, empties DO publish (they release + // holds the prune would otherwise keep protecting). + if (instanceGuid_.empty() && mine.holds.empty()) return; + if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); + // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so + // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track + // sibling's byte-identical hold set can never pass as ours (its nonce differs), so + // siblings always union and never clean-replace over each other's held paths. + if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); + mine.ownerNonce = usageNonce_; + + const std::optional existing = + bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); + const UsagePublishPlan plan = planUsagePublish(existing, mine); + if (plan.remint) { + // This state was cloned onto another track (FX copy / track duplication): take a + // fresh identity and leave the original's record untouched. The abandoned old + // identity's record dies by the extension's liveness rule when its track no + // longer hosts an instance. getState persists the new guid on the next save. + instanceGuid_ = mintUsageInstanceGuid(); + } else if (plan.skipWrite) { + return; // idle tick, or a union that adds nothing — no ext-state churn + } + bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); +} + +void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { + // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by + // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. + // + // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is + // the minimum installedAt process() published over the pointers it holds. Both + // slots are monotone in installedAt, so seen is monotone and any future process() + // load yields installedAt >= seen — an entry below seen is provably unreachable + // (see the header proof). Remaining entries drain at setActive(false) / terminate() + // when process is guaranteed stopped. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), + graveyard_.end()); + LoadedInstrument* prev = live_.exchange(built.release()); + LoadedInstrument* evicted = draining_.exchange(prev); + if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); +} + +void ReaSamplerProcessor::rebuildVoiceEngine() { + // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: + // a voice-param change touches NO audio data, so this rebuilds the engine + // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — + // and publishes through the same drain-slot swap, so ringing tails survive. + std::lock_guard lock(reloadMutex_); + LoadedInstrument* cur = live_.load(std::memory_order_acquire); + if (!cur) return; // nothing loaded: the new params bake into the next real reload. + + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + + // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap + // is immutable after construction, and under reloadMutex_ nobody can free `cur`. + Keymap km = cur->keymap; + auto built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + publishBuiltLocked(std::move(built)); +} + +void ReaSamplerProcessor::retireIdleDrain() { + // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or + // it still sounds" — the common case costs one relaxed load and no mutex. + const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); + if (idleGen == 0) return; + std::lock_guard lock(reloadMutex_); + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + // Retire ONLY if the publication names the drain currently in the slot. A stale value + // (about an already-evicted, older drain) can never match the newer occupant's + // installedAt — the slot is monotone in generation — so a mid-swap race is closed by + // this identity check, not by timing. + if (!drain || drain->installedAt != idleGen) return; + draining_.store(nullptr, std::memory_order_release); + graveyard_.push_back(std::unique_ptr(drain)); + // Prune what is now provably unreachable — the same monotone-generation proof as the + // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be + // held by process() now or ever again. The just-parked drain frees here immediately when + // process() has already published past it; otherwise on the next reload/retire/deactivate. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), + graveyard_.end()); +} + +bool ReaSamplerProcessor::legacyLiftShouldRun() { + // #A terminating guard for the pre-v10 legacy lift. The caller has already established + // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before + // paying for a full reload. Once concluded, the steady state is this one relaxed load — + // no bank read, no parse, no reload churn. + if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; + const LegacyLiftDecision decision = legacyLiftDecision( + bridge_.readReasamplerExtState(kProjExtBanksKey), + referencedSampleIds(selectedSampleId(), performanceMap())); + if (decision == LegacyLiftDecision::Stale) { + // Provably stale (the bank parses and knows none of the referenced ids): give up + // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, + // and the genChanged reload refreshes the refs without consulting this latch. + legacyLiftConcluded_.store(true, std::memory_order_relaxed); + return false; + } + return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) +} + +ReaSamplerProcessor::BankSyncResult +ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { + // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call + // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER + // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. + BankSyncResult result; + + // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer + // cadence that drives reloads — an edited-away instrument stops costing memory as soon + // as its tails die instead of squatting in the drain slot until the next reload. + retireIdleDrain(); + + // --- S8: assignment-request consume FIRST ------------------------------------- + // Decode the pending assignment request (nullopt when absent/malformed). Resolve its + // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when + // the sampleId names an existing sample (the reader requirement — an unresolvable pair is + // dropped). Then run the pure consume decision against this instance's persisted marker. + std::optional request; + if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { + request = decodeAssignmentRequest(*raw); + } + + bool resolves = false; + if (request) { + // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request + // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). + if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { + resolves = selectSample(*banksJson, request->sampleId).has_value(); + } + } + + // Read lastConsumed and conditionally write it back under a single lock scope so there + // is no interleave window between the read and the write (a concurrent getState could + // otherwise observe a stale marker between the two separate lock acquisitions). + std::int64_t lastConsumed = 0; + const AssignConsumeDecision decision = [&] { + std::lock_guard lock(assignMarkerMutex_); + lastConsumed = lastConsumedAssignGeneration_; + const AssignConsumeDecision d = + consumeDecision(request, lastConsumed, resolves, isFocusedTarget); + // Advance the persisted consumed marker whenever the decision consumed the request + // (applied OR dropped-as-seen). getState will persist it on the next project save so + // a re-open does not re-apply. A non-target instance leaves the marker (decision + // returns it unchanged) so it stays eligible if focus later lands here. + if (d.consumedGeneration != lastConsumed) { + lastConsumedAssignGeneration_ = d.consumedGeneration; + } + return d; + }(); + + if (decision.apply) { + // Apply the assignment as this instance's own selection (the same path a user card-pick + // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below + // rebuilds against the new selection, so skip a redundant reload here. + setSelectedSampleId(decision.sampleId); + // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone + // materialized for the previously loaded sample would shadow the assigned pick under + // first-match resolve. Authored maps (any narrow key range) are untouched. + PerformanceMap reconciled = performanceMap(); + if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { + setPerformanceMap(reconciled); + } + result.applied = true; + } + + // --- S9: bank-generation change-detection ------------------------------------- + // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll + // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — + // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload + // on open would only churn. A later + // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the + // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). + std::int64_t currentGen = kBankGenerationAbsent; + if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { + currentGen = parseBankGeneration(*rawGen); + } + const bool firstPoll = (lastSeenBankGeneration_ < 0); + const bool genChanged = + !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); + lastSeenBankGeneration_ = currentGen; + + // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) + // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had + // nothing to decode unless the bank happened to be readable already. Reload on this + // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs + // when readable, after which the table is non-empty and this never fires again (the + // next save is then self-contained). A deliberately-empty instance has no intent and + // never churns; a bank that is not readable YET retries a cheap null publish on the + // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob + // PARSES and no referenced id resolves in it, the ids are provably stale — there is + // nothing to lift, so the lift concludes permanently instead of churning a full bank + // read + reload every tick forever. This is a MIGRATION convenience for old projects, + // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). + bool legacyLift = false; + if (!genChanged && !result.applied && sampleRefs().empty()) { + const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); + legacyLift = hasIntent && legacyLiftShouldRun(); + } + + if (genChanged || result.applied || legacyLift) { + reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) + // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank + // view. A legacy lift counts only when it actually landed an instrument (otherwise + // every retry tick would churn the editor's caches for nothing). + result.reloaded = + genChanged || + (legacyLift && live_.load(std::memory_order_acquire) != nullptr); + } + return result; +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp new file mode 100644 index 0000000..202e82e --- /dev/null +++ b/src/shell/instrument/processor_state.cpp @@ -0,0 +1,311 @@ +// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState / +// getState against the component_state_io codec) and its UI-thread parameter +// accessors/setters (selection, performance map, channel mode, preview velocity, +// voice-system params, master gain, preview-note mailbox posts). Split out of +// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio +// thread (UI / host load-save); the setters hand work to the reload family +// (processor_reload.cpp) or store atomics process() picks up at block start. + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include + +#include "pluginterfaces/base/ibstream.h" + +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) +#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split) +#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +using namespace instrument::map; // the codec + resolution vocabulary this TU marshals + +tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { + if (!state) return kResultFalse; + // Read the whole component-state blob (the performance map, versioned). The blob is + // small; read in one shot into a growable buffer. + std::vector bytes; + std::uint8_t chunk[256]; + int32 got = 0; + while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { + bytes.insert(bytes.end(), chunk, chunk + got); + } + // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The + // selection and the zones are DISTINCT — the default face is one picked capture, zones + // are a demoted overlay — so both are restored explicitly (no more inferring a selection + // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only + // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so + // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 + // silent empty state (no first-sample fallback in reloadInstrument). + // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at + // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing + // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a + // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). + const ComponentState cs = deserializeComponentState(bytes, sampleRate_); + setSelectedSampleId(cs.selectionId); + // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a + // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the + // saved selection under first-match resolve. Reconciling here restores "the sample the + // editor shows is the sample the engine plays" for already-affected projects; authored + // Zone-view maps (any narrow key range) pass through untouched. + PerformanceMap restored = cs.map; + reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load + setPerformanceMap(restored); + // S8: restore the last-consumed assignment generation so a re-open does not re-apply a + // stale assign_request (the user may have manually changed the selection after the assign). + { + std::lock_guard lock(assignMarkerMutex_); + lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; + } + // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see + // initialize) — the mode only governs how the reload below decodes, so no bus work here. + { + std::lock_guard lock(channelModeMutex_); + channelMode_ = cs.channelMode; + channelModeExplicit_ = cs.channelModeExplicit; + } + // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 + // the editor's velocity knob is a concurrent UI-thread writer. + { + std::lock_guard lock(previewMutex_); + previewVelocity_ = cs.previewVelocity; + } + // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, + // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the + // reload below so the rebuilt engine is born with the saved polyphony/mode. + { + std::lock_guard lock(voiceParamsMutex_); + voiceCount_ = cs.voiceCount; + voiceMode_ = cs.voiceMode; + monoTrigger_ = cs.monoTrigger; + } + // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in + // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks + // it up at the next block start. + setMasterGainLinear(cs.masterGainLinear); + // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the + // reload so it decodes straight from them — no bank read required to play. A pre-v10 + // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob + // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), + // after which the next save is self-contained. + { + std::lock_guard lock(refsMutex_); + sampleRefs_ = cs.sampleRefs; + } + // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — + // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME + // for the copy-collision analysis (the fresh nonce means this incarnation can never + // be mistaken for the previous one's writes — or for a copy-sibling's). + { + std::lock_guard lock(usageMutex_); + instanceGuid_ = cs.instanceGuid; + usageNonce_.clear(); + } + // A new blob is new facts: a staleness proof latched against the PREVIOUS state does + // not carry over (#A — the legacy lift gets one fresh run per restored state). + legacyLiftConcluded_.store(false, std::memory_order_relaxed); + // Rebuild from the restored state (off-thread — setState is a load-time call). + reloadInstrument(); + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { + if (!state) return kResultFalse; + // Persist the full instance state (v3, S10): the single-capture selection id AND the + // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" + // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} + // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing + // sample #1. + ComponentState state_out; + state_out.selectionId = selectedSampleId(); + state_out.map = performanceMap(); + { + // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). + std::lock_guard lock(channelModeMutex_); + state_out.channelMode = channelMode_; + state_out.channelModeExplicit = channelModeExplicit_; + } + { + std::lock_guard lock(assignMarkerMutex_); + state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker + } + state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity + { + // Phase S: persist the voice-system parameters (component state v7). + std::lock_guard lock(voiceParamsMutex_); + state_out.voiceCount = voiceCount_; + state_out.voiceMode = voiceMode_; + state_out.monoTrigger = monoTrigger_; + } + state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) + // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to + // decode + play with no extension present. Filtered (on the snapshot copy, the member is + // untouched) to exactly what the instance currently plays, so the table cannot grow with + // browsing history. + state_out.sampleRefs = sampleRefs(); + retainRefs(state_out.sampleRefs, + referencedSampleIds(state_out.selectionId, state_out.map)); + // pS-usage: persist the publish identity (v11) so the instance's usage key is + // stable across sessions (records do not proliferate per reopen). + { + std::lock_guard lock(usageMutex_); + state_out.instanceGuid = instanceGuid_; + } + const std::vector bytes = serializeComponentState(state_out); + if (!bytes.empty()) { + const tresult wr = state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + if (wr != kResultOk) return wr; + } + return kResultOk; +} + +std::string ReaSamplerProcessor::selectedSampleId() { + std::lock_guard lock(selectionMutex_); + return selectedSampleId_; +} + +void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { + std::lock_guard lock(selectionMutex_); + selectedSampleId_ = id; +} + +PerformanceMap ReaSamplerProcessor::performanceMap() { + std::lock_guard lock(performanceMutex_); + return performanceMap_; +} + +void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { + std::lock_guard lock(performanceMutex_); + performanceMap_ = map; +} + +SampleRefs ReaSamplerProcessor::sampleRefs() { + std::lock_guard lock(refsMutex_); + return sampleRefs_; +} + +ChannelMode ReaSamplerProcessor::channelMode() { + std::lock_guard lock(channelModeMutex_); + return channelMode_; +} + +std::uint8_t ReaSamplerProcessor::previewVelocity() { + std::lock_guard lock(previewMutex_); + return previewVelocity_; +} + +void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { + // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview + // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. + if (velocity < 1) velocity = 1; + if (velocity > 127) velocity = 127; + std::lock_guard lock(previewMutex_); + previewVelocity_ = velocity; +} + +int ReaSamplerProcessor::voiceCount() { + std::lock_guard lock(voiceParamsMutex_); + return voiceCount_; +} + +void ReaSamplerProcessor::setVoiceCount(int count) { + // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's + // control can never disagree about the legal polyphony span. + if (count < kMinVoiceCount) count = kMinVoiceCount; + if (count > kMaxVoiceCount) count = kMaxVoiceCount; + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceCount_ == count) return; // no-op: don't churn a rebuild + voiceCount_ = count; + } + // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from + // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change + // touches no audio data) and the displaced instrument keeps rendering its ringing tails, + // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every + // zone from disk. Same contract for the mode/trigger setters below. + rebuildVoiceEngine(); +} + +VoiceMode ReaSamplerProcessor::voiceMode() { + std::lock_guard lock(voiceParamsMutex_); + return voiceMode_; +} + +void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceMode_ == mode) return; + voiceMode_ = mode; + } + rebuildVoiceEngine(); +} + +MonoTrigger ReaSamplerProcessor::monoTrigger() { + std::lock_guard lock(voiceParamsMutex_); + return monoTrigger_; +} + +void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { + { + std::lock_guard lock(voiceParamsMutex_); + if (monoTrigger_ == trigger) return; + monoTrigger_ = trigger; + } + rebuildVoiceEngine(); +} + +void ReaSamplerProcessor::setMasterGainLinear(double linear) { + // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = + // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; + // no rebuild, no lock (a post-sum output trim is not a keymap fact). + if (!(linear >= 0.0)) linear = 0.0; // also catches NaN + const double maxLin = masterGainMaxLinear(); + if (linear > maxLin) linear = maxLin; + masterGain_.store(static_cast(linear), std::memory_order_relaxed); +} + +void ReaSamplerProcessor::previewNoteOn(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request + // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as + // long as we never land back on the exact value the audio thread last consumed in one step — + // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). + const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + (static_cast(vel) << 8) | + static_cast(note & 0xFF); + previewOnRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::previewNoteOff(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + static_cast(note & 0xFF); + previewOffRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { + { + std::lock_guard lock(channelModeMutex_); + // The editor toggle is a DELIBERATE choice either way: latch explicit even on a + // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). + channelModeExplicit_ = true; + if (channelMode_ == mode) return; // no decode change: don't churn a reload + channelMode_ = mode; + } + // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no + // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread + // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. + reloadInstrument(); +} + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h similarity index 96% rename from src/vst/reasampler_editor.h rename to src/shell/instrument/reasampler_editor.h index 18a26ea..3de98fd 100644 --- a/src/vst/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -22,7 +22,6 @@ // to create/destroy the child window and onSize to resize it. #pragma once -#include "core/namespaces.h" #include #include @@ -47,6 +46,25 @@ class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only namespace reasampler::vst { +// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim +// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger, +// the voice-count constants, VelocityCurve via the engine re-export — stay in flat +// `reasampler` and resolve via the enclosing namespace). +using audio::AudioSample; +using audio::Envelope; +using instrument::map::BankChoice; +using instrument::map::PerformanceMap; +using instrument::map::PerformanceZone; +using instrument::map::SampleChoice; +using instrument::map::SampleRefEntry; +using instrument::map::SampleRefs; +using instrument::map::ZonePlaySeconds; +using instrument::ui::AmpEnvelope; +using instrument::ui::DeckGroupDesc; +using instrument::ui::EnvClampBounds; +using instrument::ui::EnvNode; +using instrument::ui::Rect; + class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { @@ -202,6 +220,12 @@ private: bool handlePopupMouseDown(int w, int h, int x, int y); void onMouseDown(int x, int y); + // The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the + // input TUs split along the face axis — onMouseDown keeps the Sample-face branch and + // delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical + // to the former inline branches. + void mouseDownBrowse(int w, int h, int x, int y); + void mouseDownZone(int w, int h, int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); // r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only diff --git a/src/shell/instrument/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp index e3c46bf..f5b8ddc 100644 --- a/src/shell/instrument/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -16,7 +16,7 @@ #include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey #include "shell/instrument/reaper_bridge.h" -#include "reasampler_processor.h" +#include "shell/instrument/reasampler_processor.h" #include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3) // wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp new file mode 100644 index 0000000..8f11876 --- /dev/null +++ b/src/shell/instrument/reasampler_processor.cpp @@ -0,0 +1,425 @@ +// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is +// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface, +// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview +// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter +// accessors live in processor_state.cpp; the off-thread reload/publish family lives in +// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual +// seam, no cross-TU call on the per-sample path. + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include + +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstevents.h" +#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) +#include "pluginterfaces/vst/vstspeaker.h" + +#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor +#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +namespace { + +// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a +// linear per-sample step derived from this at setupProcessing (gainRampStep_ = +// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing +// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as +// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is +// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ +// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. +constexpr double kGainRampSeconds = 0.020; + +} // namespace + +FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { + // The host owns the returned reference. Cast up to the combined interface the SDK + // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. + return static_cast(new ReaSamplerProcessor()); +} + +// Out-of-line so unique_ptr sees the complete type here. +ReaSamplerProcessor::~ReaSamplerProcessor() = default; + +tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { + // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for + // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed + // shell. We own the shell (unique_ptr); the borrowed reference is valid because the + // processor outlives it. All other iids fall through to the SDK's queryInterface. + if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { + if (!embed_) embed_ = std::make_unique(this); + embed_->addRef(); + *obj = static_cast(embed_.get()); + return kResultOk; + } + return SingleComponentEffect::queryInterface(iid, obj); +} + +tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { + tresult result = SingleComponentEffect::initialize(context); + if (result != kResultOk) return result; + + // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the + // instrument still loads, it just has no live bank to play. + bridge_.connect(context); + + // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no + // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of + // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders + // dual-mono through the stereo bus (both channels equal, centered), which is audibly + // identical to a mono bus but never asks the host to re-map a live instance's pins. The + // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every + // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The + // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer + // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's + // re-routing of the live instance's pins across the arrangement change. A fixed arrangement + // is the maximally-standard VSTi shape and removes that whole negotiation surface. + addEventInput(STR16("MIDI In"), 16); + addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); + + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::terminate() { + // process() is not running at terminate: free the live + draining instruments and + // drain the graveyard. Take the pointers out of the atomics first so nothing else + // races them. + std::lock_guard lock(reloadMutex_); + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); + graveyard_.clear(); + return SingleComponentEffect::terminate(); +} + +tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { + // Activating: build the instrument from the currently-selected sample so the first + // block after activation can play. Deactivating: process is now GUARANTEED stopped by + // the host, so this is the safe point to reclaim the graveyard (the displaced engines + // no reload could free while active). The build/drain are off the audio thread — + // setActive is a main/UI-thread call. + if (state) { + // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED + // sample refs — it needs no bank read, so it plays regardless of whether the + // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). + // + // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a + // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic + // refreshRefsFromBank copies the refs in when the bank blob is readable by + // activation time, so an upgraded project plays on load without the instrument + // ever being opened (and the next save is self-contained). Residual load-order + // race, DAW-verifiable only: if the host activates this instance BEFORE the + // project's ext-state lines parse, the lift misses here and — with no editor open — + // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a + // pre-v10 instrument once after upgrading if it restores silent. + reloadInstrument(); + } else { + std::lock_guard lock(reloadMutex_); + // process is guaranteed stopped: free EVERYTHING. The live instrument too — its + // voices are frozen mid-flight, and if it survived deactivation the reactivate + // reload would displace it into the DRAIN slot, resurrecting stale sustained + // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), + // so nothing is lost by clearing here. + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); + graveyard_.clear(); + } + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { + sampleRate_ = setup.sampleRate; + maxBlockSize_ = setup.maxSamplesPerBlock; + // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at + // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host + // guarantees setupProcessing never overlaps process), read on the audio thread only. + if (sampleRate_ > 0.0) { + gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); + } + return SingleComponentEffect::setupProcessing(setup); +} + +tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( + SpeakerArrangement* inputs, int32 numIns, + SpeakerArrangement* outputs, int32 numOuts) { + // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a + // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. + // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) + // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a + // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. + if (numIns < 0 || numOuts < 0) return kInvalidArgument; + if (numIns > 0) return kResultFalse; // no audio input bus to arrange + if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { + // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments + // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt + // over the pointers held so the off-thread graveyard pruner knows exactly which + // generations this block is holding (see the header proof). + // + // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an + // ordering race: reading reloadGeneration_ after the slots could observe a generation + // newer than the pointers we actually hold, causing the pruner to free an instrument + // process is still reading. installedAt was set on the reload path before the atomic + // exchange that made the instrument visible. + // + // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the + // last reload: its already-sounding voices keep rendering (and receive note-offs) so a + // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. + // A racing reload can briefly leave the same pointer in both slots (live_ was loaded + // before the swap, draining_ after); collapse that to live-only so one engine is never + // advanced twice per frame. + LoadedInstrument* inst = live_.load(std::memory_order_acquire); + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + if (drain == inst) drain = nullptr; + std::uint64_t heldGen = 0; + if (inst && drain) { + heldGen = inst->installedAt < drain->installedAt ? inst->installedAt + : drain->installedAt; + } else if (inst) { + heldGen = inst->installedAt; + } else if (drain) { + heldGen = drain->installedAt; + } + processGeneration_.store(heldGen, std::memory_order_release); + + // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine + // voice silent) by naming its OWN installedAt (0 = no drain / still + // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no + // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply + // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. + drainIdleGeneration_.store( + (drain && drain->fullyIdle()) ? drain->installedAt : 0, + std::memory_order_relaxed); + + // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps + // events at block granularity (no per-event sample-offset split) — audible timing is + // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. + // Note-offs also route to the DRAIN engine so a note held across a reload releases + // its old-snapshot voice too (otherwise it would sustain until the next reload). + if (data.inputEvents) { + const int32 count = data.inputEvents->getEventCount(); + for (int32 i = 0; i < count; ++i) { + Event e; + if (data.inputEvents->getEvent(i, e) != kResultOk) continue; + if (e.type == Event::kNoteOnEvent) { + // A note-on with velocity 0 is a note-off by MIDI convention. + const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); + if (vel <= 0) { + if (inst) inst->engine.noteOff(e.noteOn.pitch); + if (drain) drain->engine.noteOff(e.noteOn.pitch); + } else if (inst) { + inst->engine.noteOn(e.noteOn.pitch, vel); + } + } else if (e.type == Event::kNoteOffEvent) { + if (inst) inst->engine.noteOff(e.noteOff.pitch); + if (drain) drain->engine.noteOff(e.noteOff.pitch); + } else if (e.type == Event::kLegacyMIDICCOutEvent) { + // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a + // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism + // — the type is nominally an output event; DAW-verify, see handoff). + // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR + // release tail; Trigger one-shots play through their bounded play length. + // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless + // of play mode, including Trigger one-shots that ignore CC 123. This is the + // true "panic" for a ringing one-shot (e.g. a full-length capture). + // Both clear the mono held stack. Both apply to live AND drain. A ringing + // preview note is a real engine voice since the PreviewCard retirement, so + // the panics cover it with no separate routing. allNotesOff / allSoundsOff + // are RT-safe (no allocation, bounded scans). + const auto cc = static_cast(e.midiCCOut.controlNumber); + if (cc == kCtrlAllSoundsOff) { + if (inst) inst->engine.allSoundsOff(); + if (drain) drain->engine.allSoundsOff(); + } else if (cc == kCtrlAllNotesOff) { + if (inst) inst->engine.allNotesOff(); + if (drain) drain->engine.allNotesOff(); + } + } + } + } + + // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed + // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last + // one we consumed; fire it once, then latch the sequence so the same request never re-fires. + // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact + // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real + // voice: it counts against the voice count, can steal / be stolen, and respects + // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's + // isolation). The editor posts the root note, so it plays at unity. + // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument + // is loaded does not re-fire stale on the next instrument load. + { + const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); + const std::uint16_t onSeq = static_cast(on >> 16); + if (onSeq != 0 && onSeq != previewOnConsumed_) { + previewOnConsumed_ = onSeq; + if (inst) { + const int vel = static_cast((on >> 8) & 0xFF); + const int note = static_cast(on & 0xFF); + if (vel > 0) inst->engine.noteOn(note, vel); + } + } + } + { + const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); + const std::uint16_t offSeq = static_cast(off >> 16); + if (offSeq != 0 && offSeq != previewOffConsumed_) { + // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending + // while nothing was loaded would otherwise survive until a (heal) reload lands + // and release the NEXT preview press in the same block. + previewOffConsumed_ = offSeq; + // Route the preview note-off to BOTH engines (mirror of the host note-off): a + // preview held across a reload — e.g. a curve edit committed mid-press — must + // release the old-snapshot voice now draining, not just the (fresh) live one. + // NOTE: preview shares the host-MIDI note space — noteOff releases the newest + // voice at that pitch, so a preview release can release a host-held note at + // the same pitch (inherent to routing preview through the real note path). + if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); + if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); + } + } + + if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { + embedPeak_.store(0.f, std::memory_order_relaxed); + return kResultOk; + } + AudioBusBuffers& out = data.outputs[0]; + const int32 frames = data.numSamples; + + // 64-bit host processing is not supported by the mono float core; emit silence + // rather than mis-render. REAPER runs 32-bit float by default. + if (data.symbolicSampleSize != kSample32) { + embedPeak_.store(0.f, std::memory_order_relaxed); + for (int32 ch = 0; ch < out.numChannels; ++ch) { + if (double* buf = out.channelBuffers64[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; + } + } + out.silenceFlags = (out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1); + return kResultOk; + } + + // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked + // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here + // we simply match the buffers the host handed us: >=2 channels -> true stereo render into + // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the + // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. + float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; + float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; + if (ch0 && ch1) { + // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo + // path (both channels equal), so a mono capture in stereo mode is centered, not silent. + // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). + for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } + if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); + if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); + // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so + // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. + // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the + // actual output. Branch-free inner loop; early-out when already at target. RT-safe. + { + const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; + const float diff = gTarget - gainCurrent_; + if (diff < -gSnap || diff > gSnap) { + // Ramp toward target: step per sample, then apply the per-sample gain. + for (int32 i = 0; i < frames; ++i) { + const float d = gTarget - gainCurrent_; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; + else gainCurrent_ = gTarget; + ch0[i] *= gainCurrent_; + ch1[i] *= gainCurrent_; + } + } else { + gainCurrent_ = gTarget; + if (gTarget != 1.f) { + for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } + } + } + } + // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + for (int32 ch = 2; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; + if (a0 > peak) peak = a0; + if (a1 > peak) peak = a1; + } + embedPeak_.store(peak, std::memory_order_relaxed); + } else if (ch0) { + // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; + // the replicate is defensive for a host that still hands >1 channel on a mono bus). + for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; + if (inst) inst->engine.render(ch0, static_cast(frames)); + if (drain) drain->engine.render(ch0, static_cast(frames)); + // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: + // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. + { + const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; + const float diff = gTarget - gainCurrent_; + if (diff < -gSnap || diff > gSnap) { + for (int32 i = 0; i < frames; ++i) { + const float d = gTarget - gainCurrent_; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; + else gainCurrent_ = gTarget; + ch0[i] *= gainCurrent_; + } + } else { + gainCurrent_ = gTarget; + if (gTarget != 1.f) { + for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; + } + } + } + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + if (a > peak) peak = a; + } + embedPeak_.store(peak, std::memory_order_relaxed); + for (int32 ch = 1; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + } + + // Report silence only when nothing is loaded (lets the host optimize when idle). + // With an instrument loaded — or a drain snapshot still ringing out — we clear the + // flag so a ringing voice is not skipped. + out.silenceFlags = (inst || drain) ? 0 + : ((out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1)); + return kResultOk; +} + +IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { + if (name && FIDStringsEqual(name, ViewType::kEditor)) { + return new ReaSamplerEditor(this); + } + return nullptr; +} + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h similarity index 98% rename from src/vst/reasampler_processor.h rename to src/shell/instrument/reasampler_processor.h index e339048..3c01c6c 100644 --- a/src/vst/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -25,7 +25,6 @@ // single atomic pointer swap. See the LoadedInstrument handoff below. #pragma once -#include "core/namespaces.h" #include #include @@ -38,10 +37,20 @@ #include "shell/instrument/reaper_bridge.h" #include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) +#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split) #include "core/instrument/engine/sampler_core.h" namespace reasampler::vst { +// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim +// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine, +// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat +// `reasampler` and resolve via the enclosing namespace). +using instrument::map::ComponentState; +using instrument::map::PerformanceMap; +using instrument::map::SampleRefs; +using instrument::map::kPreviewVelocityDefault; + class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface) // One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice diff --git a/src/shell/instrument/vst_entry.cpp b/src/shell/instrument/vst_entry.cpp index 4b32533..f41070c 100644 --- a/src/shell/instrument/vst_entry.cpp +++ b/src/shell/instrument/vst_entry.cpp @@ -24,7 +24,7 @@ #include "core/version/app_version.h" // vstPluginName / appVersion — the channel-derived identity #include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target -#include "reasampler_processor.h" +#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) // CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp deleted file mode 100644 index 0bf74fb..0000000 --- a/src/vst/reasampler_editor.cpp +++ /dev/null @@ -1,3084 +0,0 @@ -#include "core/namespaces.h" -#include "core/util/clamp01.h" -// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the -// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is -// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. - -#include "reasampler_editor.h" - -#include -#include -#include // snprintf (Phase S voice-count readout) -#include -#include -#include - -#include "core/instrument/ui/browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry -#include "core/instrument/ui/capture_browser.h" -#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) -#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) -#include "core/instrument/ui/editor_geometry.h" // Rect, contains -#include "ext_keys.h" -#include "core/instrument/ui/keyboard_strip.h" -#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) -#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor (L3) -#include "core/instrument/map/note_entry.h" // S12 direct numeric note-entry parse -#include "core/instrument/ui/param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta) -#include "core/audio/peaks.h" // computeEnvelope -#include "shell/instrument/reaper_bridge.h" -#include "reasampler_processor.h" -#include "core/version/app_version.h" // vstPluginName (channel-derived editor title band, S18) -#include "core/instrument/map/sample_map.h" -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames -#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) -#include "core/instrument/ui/waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) - -#ifdef _WIN32 -#include // GET_X_LPARAM / GET_Y_LPARAM -#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept - -#include "wdltypes.h" -#include "lice/lice.h" -#endif - -using namespace Steinberg; - -namespace reasampler::vst { - -namespace { -#ifdef _WIN32 -constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; - -// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread -// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence" -// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many -// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough -// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero). -constexpr UINT_PTR kSyncTimerId = 1; -constexpr UINT kSyncTimerIntervalMs = 500; - -// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry is in -// capture_browser / keyboard_strip / knob_deck). r11 Sample face (top->bottom): a TITLE band -// (name + Browse/Zone nav buttons), the FULL-WIDTH ELASTIC HERO (absorbs all height left after -// the fixed bands, floor kHeroMinHeight — the S11 markers + the S-VIEW-3 envelope overlay trace -// over it), the ROOT + PREVIEW CLUSTER (remainder-width root strip + preview-trigger + radial -// velocity knob + mini curve-preview button + Mono/Stereo), and the bottom-anchored KNOB DECK -// (the fenced control groups — the r11 replacement for the slider control strip). Browse + Zone -// reuse the browser grid / zone strip machinery unchanged. -constexpr int kTitleHeight = 26; -constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) -constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle -constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) -constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons - -// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's -// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class, -// §2.1), not the live/active layer, so they take the categorical accents: start = teal -// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple. -constexpr Role kRoleStartMarker = Role::AccentSecondary; -constexpr Role kRoleLoopMarker = Role::AccentTertiary; - -// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- -// -// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox` -// (component_geometry). This is the single boundary that bridges them so every draw routes -// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette + -// GDI DrawTextA path. -KitBox toKitBox(const Rect& r) { - return KitBox{r.x, r.y, r.width, r.height}; -} - -// Kit text in a palette ROLE (the common case). Left/Right/Center via Align. -void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role, - Align align = Align::Left) { - text(bmp, toKitBox(r), s, font, role, align); -} - -void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) { - text(bmp, toKitBox(r), s, font, role, Align::Center); -} - -// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the -// common DAW convention REAPER uses). -std::string noteLabel(int note) { - static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", - "F#", "G", "G#", "A", "A#", "B"}; - if (note < 0) note = 0; - if (note > 127) note = 127; - const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 - return std::string(kNames[note % 12]) + std::to_string(octave); -} - -// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3): -// midline + accent-primary min/max columns with the same dB display compression the dock -// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has -// already filled the surface behind it (bg/panel), matching drawWaveform's contract. -void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) { - drawWaveform(bmp, toKitBox(r), env); -} - -// A display name for a bank sample id: the snapshotted bank list first, then the -// instance-OWNED ref's displayName (pS — the label survives with the extension absent / -// bank unreadable, mirroring the waveform + loop-marker ref fallback). "?" only when -// neither source knows the id (a stale zone naming a deleted sample, or a pre-displayName -// refs table not yet back-filled by a bank refresh). -std::string sampleLabel(const std::vector& samples, const SampleRefs& refs, - const std::string& id) { - for (const SampleChoice& c : samples) { - if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; - } - for (const SampleRefEntry& e : refs) { - if (e.sampleId == id && !e.displayName.empty()) return e.displayName; - } - return "?"; -} - -// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the -// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. -// thumbnailFor clamps the request to the decoded frame count. -int thumbBins(const BrowserLayout& layout) { - return (std::max)(1, kWaveformOversample * - waveformColumnCount(toKitBox(cardThumbnailRect(layout, 0)))); -} -#endif -} // namespace - -ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) - : CPluginView(nullptr), processor_(processor) { - // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample - // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate - // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll - // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns - // read comfortably. - ViewRect r(0, 0, 840, 620); - setRect(r); -} - -void ReaSamplerEditor::refreshFromBank() { - // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). - thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks - pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) - if (!processor_) { - samples_.clear(); - banks_.clear(); - visible_.clear(); - selectedId_.clear(); - map_.zones.clear(); - selectedZone_ = -1; - return; - } - auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; - banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; - selectedId_ = processor_->selectedSampleId(); - const auto prevZoneCount = static_cast(map_.zones.size()); - map_ = processor_->performanceMap(); - channelMode_ = processor_->channelMode(); - voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot - voiceMode_ = processor_->voiceMode(); - monoTrigger_ = processor_->monoTrigger(); - if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; - // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the - // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible - // modal would swallow clicks. - if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; - // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list - // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list - // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). - if (view_ == View::kZone && curvePopupOpen_) { - const auto newZoneCount = static_cast(map_.zones.size()); - if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; - } - // Drop a filter that names a bank no longer present. - if (!activeFilterBankId_.empty()) { - bool found = false; - for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; - if (!found) activeFilterBankId_.clear(); - } - rebuildVisible(); -} - -void ReaSamplerEditor::rebuildVisible() { - // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search - // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). - visible_.clear(); - for (const SampleChoice& s : samples_) { - const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; - if (!inBank) continue; - const std::string& name = s.displayName.empty() ? s.id : s.displayName; - if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); - } - // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel - // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it - // must not reset the user's scroll here. -} - -#ifdef _WIN32 -// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared -// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes -// Windows the only build target, but the TU must still compile elsewhere). -void ReaSamplerEditor::onSyncTimer() { - // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the - // processor (off the audio thread — the poll itself never touches process()). NEVER while a - // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the - // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. - if (!processor_) return; - if (drag_ != DragKind::kNone) return; // defer past the in-flight edit - - // An open editor marks THIS instance the focused assignment target (the thundering-herd - // policy — only an editor-open instance applies a pending assign; see the handoff). Pass - // true so this instance consumes the request; instances with no editor open do not poll at - // all (the timer is bound to the child window), so they never contend for the request. - const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); - - // Re-snapshot the editor's own view only when something changed (a reload from a bank - // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the - // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM - // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. - if (r.reloaded || r.applied) { - refreshFromBank(); - invalidate(); - } - - // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. - if (dropHintTicks_ > 0) { - --dropHintTicks_; - invalidate(); - } -} -#endif // _WIN32 - -void ReaSamplerEditor::commitAndReload() { - // UI thread only. Publish the edited selection + zones to the processor, then rebuild - // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). - // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank - // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the - // moment the instance becomes self-contained for that sample. - if (!processor_) return; - processor_->setSelectedSampleId(selectedId_); - processor_->setPerformanceMap(map_); - processor_->reloadInstrument(); - // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's - // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode - // the engine actually decoded with. - channelMode_ = processor_->channelMode(); -#ifdef _WIN32 - invalidate(); -#endif -} - -void ReaSamplerEditor::loadSelection(const std::string& id) { - // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous - // sample's materialized full-range zone must not linger — first-match resolve would - // keep playing it while the editor draws the new pick's zone (matched by sampleId, - // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. - selectedId_ = id; - if (reconcileSingleCaptureZones(map_, selectedId_)) { - selectedZone_ = map_.zones.empty() ? -1 : 0; - } - commitAndReload(); -} - -ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { - SetupMarkers m; - // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override - // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic - // from the live bank blob (the same path selectSample uses); when that is not readable - // (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics - // (pS fallback). The override lives in map_. - if (processor_) { - std::optional sel; - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) sel = selectSample(*banksJson, selectedId_); - if (!sel) { - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; - } - if (sel && sel->loop.hasLoop) { - m.hasLoop = true; - m.loopStart = sel->loop.start; - m.loopEnd = sel->loop.end; - } - } - // The override (loop + start) on a zone for the picked id supersedes the intrinsic. - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId != selectedId_) continue; - if (z.loopOverride) { - m.hasLoop = z.loopOverride->hasLoop; - m.loopStart = z.loopOverride->start; - m.loopEnd = z.loopOverride->end; - } - if (z.startPoint) m.start = *z.startPoint; - break; - } - // Default an unset loop's end to the sample length so the loop markers have somewhere sane - // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; - // the markers are still drawn (drag one to CREATE a loop). - if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; - return m; -} - -int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { - // Find-or-append the zone for selectedId_ and write the loop/start override fields. - // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must - // be non-empty; callers are responsible for that guard. - // Returns the zone index (0-based) so callers can update selectedZone_. - SampleLoop loop; - loop.hasLoop = m.hasLoop; - loop.start = m.loopStart; - loop.end = m.loopEnd; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - PerformanceZone& z = map_.zones[static_cast(i)]; - if (z.sampleId == selectedId_) { - z.loopOverride = loop; - z.startPoint = m.start; - return i; - } - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.loopOverride = loop; - z.startPoint = m.start; - map_.zones.push_back(z); - return static_cast(map_.zones.size()) - 1; -} - -PerformanceZone ReaSamplerEditor::effectiveSampleZone() const { - // The picked id's one-zone override, if the map already carries one; else a product-default - // zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes - // it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean. - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_) return z; - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - return z; -} - -int ReaSamplerEditor::effectiveRoot() const { - int root = 60; - for (const SampleChoice& s : samples_) { - if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; - } - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; - } - return root; -} - -int ReaSamplerEditor::ensureSampleZone() { - if (selectedId_.empty()) return -1; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - map_.zones.push_back(z); - return static_cast(map_.zones.size()) - 1; -} - -namespace { -// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span -// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build -// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) -// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is -// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — -// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the -// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate -// ruling). Build-time residual — one place to retune; not persisted. -constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) -constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) -constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered -constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) - -} // namespace - -double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { - // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over - // the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate. - // A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 - // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old - // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. - const double fadeMax = fadeMaxFrames(); - const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; - const auto framesToNorm = [fadeMax](std::int64_t f) { - // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. - return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; - }; - switch (static_cast(id)) { - case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; - case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; - case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); - case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); - case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); - case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); - case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); - case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); - case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); - case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); - case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; - case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); - case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); - case ParamControl::kPitchEnvDepth: - // Signed depth centered at 0.5 (0.5 == 0 semitones). - return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); - default: return 0.0; - } -} - -void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, - int segment) const { - const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale - const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; - const auto normToFrames = [fadeMax](double v) -> std::int64_t { - // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. - if (fadeMax <= 0.0) return 0; - return static_cast(clamp01(v) * fadeMax + 0.5); - }; - switch (static_cast(id)) { - case ParamControl::kPlayMode: - play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; - break; - case ParamControl::kPitchEngine: - play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; - break; - case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; - case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; - case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; - case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; - case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; - case ParamControl::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 ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; - case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; - case ParamControl::kPitchEnvEnable: - play.pitchEnv.enabled = (segment == 1); - break; - case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; - case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; - case ParamControl::kPitchEnvDepth: - play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; - break; - default: break; - } -} - -double ReaSamplerEditor::liveSampleRate() const { - return processor_ ? processor_->sampleRate() : 0.0; -} - -double ReaSamplerEditor::fadeMaxFrames() const { - // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) - // resolved against the live rate — the SAME time base the envelope overlay already uses - // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), - // and the rate captures are made at (the capture path renders at the project rate). - // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the - // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — - // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert - // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. - const double rate = liveSampleRate(); - if (rate <= 0.0) return 0.0; - return kFadeMaxSeconds * rate; -} - -double ReaSamplerEditor::previewVelocity01() const { - if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; - return static_cast(processor_->previewVelocity()) / 127.0; -} - -// --- r11 knob-deck plumbing (FB1) --------------------------------------------- - -namespace { -// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck order. -enum DeckGroup { - kGroupAmpEnv = 0, - kGroupPitch, - kGroupPitchEnv, - kGroupVoice, - kGroupMaster, -}; -} // namespace - -std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { - // The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders - // exactly these; the Sample face appends the per-instance groups in deckGroupDescs). - // Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger - // leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows - // the neighbouring groups (r11). - std::vector out; - { - DeckGroupDesc amp; - amp.id = kGroupAmpEnv; - amp.captionWidth = 78; - amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; - if (play.playMode == PlayMode::Gate) { - amp.cellIds = {static_cast(ParamControl::kAttack), - static_cast(ParamControl::kHold), - static_cast(ParamControl::kDecay), - static_cast(ParamControl::kSustain), - static_cast(ParamControl::kRelease)}; - } else { - // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — - // matches the drawn envelope), plus the two reserved blanks. - amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), - static_cast(ParamControl::kTrigLength), - static_cast(ParamControl::kTrigFadeOut), -1, -1}; - } - out.push_back(std::move(amp)); - } - { - DeckGroupDesc pitch; - pitch.id = kGroupPitch; - pitch.captionWidth = 38; - pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; - pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; - out.push_back(std::move(pitch)); - } - { - DeckGroupDesc penv; - penv.id = kGroupPitchEnv; - penv.captionWidth = 58; - penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; - penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), - static_cast(ParamControl::kPitchEnvDecay), - static_cast(ParamControl::kPitchEnvDepth)}; - out.push_back(std::move(penv)); - } - return out; -} - -std::vector ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const { - // The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER - // groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the - // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays - // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. - std::vector out = zoneDeckGroupDescs(play); - { - DeckGroupDesc voice; - voice.id = kGroupVoice; - voice.captionWidth = 38; - voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; - voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; - voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; - out.push_back(std::move(voice)); - } - { - DeckGroupDesc master; - master.id = kGroupMaster; - master.captionWidth = 46; - master.cellIds = {static_cast(ParamControl::kMasterGain)}; - out.push_back(std::move(master)); - } - return out; -} - -double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { - if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob - switch (static_cast(id)) { - case ParamControl::kKeyTrack: - return clamp01(zone.keyTrack / kKeyTrackMax); - case ParamControl::kVoiceCount: - return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / - static_cast(kMaxVoiceCount - kMinVoiceCount)); - case ParamControl::kMasterGain: - return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0); - default: - return controlValue(id, zone.play); - } -} - -void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { - if (!processor_) return; - norm = clamp01(norm); - if (id == -2) { - // Preview velocity: live processor write (persisted per-instance; the setter clamps - // to MIDI 1..127 so the knob's bottom still strikes audibly). - processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); - return; - } - switch (static_cast(id)) { - case ParamControl::kVoiceCount: { - // Stepped: quantize the continuous drag to the integer count and track it live - // for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on - // WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild, - // not thirty. - const int count = - kMinVoiceCount + - static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); - voiceCount_ = count; - return; - } - case ParamControl::kMasterGain: - // Post-mixer gain: one atomic store; the audio thread picks it up next block. - processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); - return; - default: - applyZoneControl(zoneIndex, id, norm, 0); - return; - } -} - -std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { - char buf[24]; - buf[0] = '\0'; - const ZonePlaySeconds& play = zone.play; - switch (id == -2 ? ParamControl::kCount : static_cast(id)) { - case ParamControl::kAttack: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; - case ParamControl::kHold: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; - case ParamControl::kDecay: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; - case ParamControl::kSustain: - snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; - case ParamControl::kRelease: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; - case ParamControl::kTrigLength: - snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; - case ParamControl::kTrigFadeIn: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(play.trigger.fadeInFrames)); break; - case ParamControl::kTrigFadeOut: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(play.trigger.fadeOutFrames)); break; - case ParamControl::kPitchEnvAttack: - snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; - case ParamControl::kPitchEnvDecay: - snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; - case ParamControl::kPitchEnvDepth: - snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; - case ParamControl::kKeyTrack: - snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break; - case ParamControl::kVoiceCount: - snprintf(buf, sizeof(buf), "%d", voiceCount_); break; - case ParamControl::kMasterGain: - formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; - default: - // -2 (preview velocity) is labeled at its cluster call site; nothing else here. - break; - } - return std::string(buf); -} - -EnvClampBounds ReaSamplerEditor::envClampBounds() const { - // Match the control-panel sliders' own domains so a node drag can never produce a param a - // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the - // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). - EnvClampBounds b; - b.maxAttackSeconds = kEnvTimeMaxSeconds; - b.maxHoldSeconds = kEnvTimeMaxSeconds; - b.maxDecaySeconds = kEnvTimeMaxSeconds; - b.maxReleaseSeconds = kEnvTimeMaxSeconds; - b.maxFadeInFraction = 1.0; - b.maxFadeOutFraction = 1.0; - b.maxLengthFraction = 1.0; - return b; -} - -AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, - std::int64_t startFrame) const { - AmpEnvelope env; - env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; - // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). - env.attackSeconds = play.adsr.attackSeconds; - env.holdSeconds = play.adsr.holdSeconds; - env.decaySeconds = play.adsr.decaySeconds; - env.sustainLevel = play.adsr.sustainLevel; - env.releaseSeconds = play.adsr.releaseSeconds; - // Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played - // span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start - // point so the fraction denominator matches the voice's actual post-start span. A zero play - // length yields 0 fractions. - env.lengthFraction = play.trigger.lengthFraction; - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); - env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); - return env; -} - -void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, - std::int64_t startFrame, ZonePlaySeconds& play) const { - if (env.mode == EnvMode::Gate) { - play.adsr.attackSeconds = env.attackSeconds; - play.adsr.holdSeconds = env.holdSeconds; - play.adsr.decaySeconds = env.decaySeconds; - play.adsr.sustainLevel = env.sustainLevel; - play.adsr.releaseSeconds = env.releaseSeconds; - } else { - // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over - // the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's - // effective start point so the frame denominator matches the voice's actual post-start span. - // Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length - // trigger never plays nothing. - play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); - play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); - } -} - -void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { - // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, - // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays - // identically to the un-zoned single capture (one chromatic zone) and round-trips through - // the component state; the zone becomes visible if the user opens the Zones panel. The bank - // intrinsic is NEVER written (read-only bank consumer, D-B). - if (selectedId_.empty()) return; - upsertPickedOverride(m); - commitAndReload(); -} - -const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { - auto it = pcmCache_.find(sampleId); - if (it != pcmCache_.end()) return it->second; - - // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so - // resolve the path from the live bank blob (selectSample) and decode via the shared WAV - // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY - // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- - // independent) — the thumbnail bins this at whatever width, the snap scans it directly. - std::string relativePath; - std::vector mono; - if (processor_) { - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) { - if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; - } - if (relativePath.empty()) { - // pS fallback: the bank blob is not readable (extension absent / not yet parsed) - // or the id went stale there — the instance-OWNED ref still carries the path, so - // a self-contained instance draws its loaded sound's waveform regardless. - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, sampleId)) { - relativePath = r->relativePath; - } - } - if (!relativePath.empty()) { - const std::string projectDir = processor_->bridge().activeProjectDir(); - const std::string abs = resolveBankFile(projectDir, relativePath); - // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. - const std::vector bytes = readFileBytes(abs); - const WavLayout layout = parseWavLayout(bytes); - if (layout.valid) { - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, layout.frameCount()); - mono = downmixToMono(interleaved, layout.channelCount); - } - } - } - auto ins = pcmCache_.emplace(sampleId, std::move(mono)); - return ins.first->second; -} - -const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { - const std::string key = sampleId + "|" + std::to_string(binCount); - auto it = thumbCache_.find(key); - if (it != thumbCache_.end()) return it->second; - - // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by - // every thumbnail width AND the S11 waveform surface + snap. - const std::vector& mono = monoPcmFor(sampleId); - Envelope env; - if (!mono.empty()) { - // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with - // trailing empty {0,0} bins, which would render a very short sample as a comb of - // spikes over flat gaps. - const std::size_t bins = - (std::min)(static_cast((std::max)(1, binCount)), mono.size()); - env = computeEnvelope(mono, 1, mono.size(), bins); - } - auto ins = thumbCache_.emplace(key, std::move(env)); - return ins.first->second; -} - -ReaSamplerEditor::~ReaSamplerEditor() { -#ifdef _WIN32 - if (childHwnd_) { - DestroyWindow(childHwnd_); - childHwnd_ = nullptr; - } -#endif -} - -tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { -#ifdef _WIN32 - if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; -#endif - return kResultFalse; -} - -tresult PLUGIN_API ReaSamplerEditor::canResize() { - return kResultTrue; -} - -tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { - // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before - // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the - // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title - // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully - // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is - // above this floor. - constexpr int kMinW = 560; - constexpr int kMinH = 460; - if (!rect) return kResultFalse; - if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; - if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; - return kResultTrue; -} - -#ifdef _WIN32 - -void ReaSamplerEditor::invalidate() { - if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); -} - -void ReaSamplerEditor::attachedToParent() { - HWND parent = static_cast(systemWindow); - if (!parent) return; - - HINSTANCE hInst = - reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); - if (!hInst) hInst = GetModuleHandle(nullptr); - - static bool classRegistered = false; - if (!classRegistered) { - WNDCLASSW wc{}; - wc.lpfnWndProc = &ReaSamplerEditor::wndProc; - wc.hInstance = hInst; - wc.lpszClassName = kChildClassName; - wc.hCursor = LoadCursor(nullptr, IDC_ARROW); - wc.style = CS_HREDRAW | CS_VREDRAW; - RegisterClassW(&wc); - classRegistered = true; - } - - // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a - // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on - // editor close: the embed strip in the SAME binary shares the kit's process-global font - // set, so a per-view shutdown could free fonts still in use by the other view. The tiny - // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. - kitFontsInit(); - - refreshFromBank(); - - const ViewRect& r = getRect(); - childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, - r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); - if (childHwnd_) { - SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); - // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT - // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show - // the "drop on the panel" affordance instead of the OS bouncing the drop silently. - DragAcceptFiles(childHwnd_, TRUE); - // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's - // lifetime — created here, killed in removedFromParent — so an instance whose editor - // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). - SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); - // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor - // was closed) or a bank change applies the instant the editor opens, rather than waiting - // up to one timer interval. refreshFromBank above already primed the view; this folds in - // any pending assign/generation so the just-opened editor shows the assigned capture. - onSyncTimer(); - } -} - -void ReaSamplerEditor::removedFromParent() { - if (childHwnd_) { - KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away - DestroyWindow(childHwnd_); - childHwnd_ = nullptr; - } -} - -tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - tresult res = CPluginView::onSize(newSize); - if (childHwnd_ && newSize) { - MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); - thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them - } - return res; -} - -// The Sample-view (S-VIEW-2) bands. The TITLE band names the plugin + a live readout and hosts -// the Browse/Zone nav buttons at its right; the HERO band is the enlarged waveform + envelope -// overlay; the CLUSTER band is the fenced root strip + preview + channel toggle; the CONTROL band -// is the param panel. Every band is padded 8px horizontally by its consumers. Browse + Zone views -// derive their own areas from `title` + `content` below. -namespace { -constexpr int kPad = 8; - -// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the curve -// in the POPUP (curve_popup), each summoned from its own mini preview button — the Sample -// cluster's and the Zone panel's (the inline Zone box is retired). The INSET keeps node handles -// + the pick radius inside the border so an endpoint at amp 0/1 stays grabbable — the ONE -// curveBoxFromRect grammar the popup derives its mapping box through. -constexpr int kVelCurveInset = 14; // border -> mapping-box inset: keeps endpoint handles + pick radius inside the border -constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete - -// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial -// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). -constexpr int kPreviewBtnW = 64; -constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) -constexpr int kCurveBtnSize = 28; // the square curve-preview button - -struct SampleBands { - Rect title; // top: name + Browse/Zone nav buttons - Rect navBrowse; // the "Browse" title-band button - Rect navZone; // the "Zone" title-band button - Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay (r11) - Rect cluster; // root strip + preview + vel knob + curve button + channel toggle - Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) -}; -// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed -// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- -// anchored). When the window is too short for the floor (below the checkSizeConstraint -// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the -// window bottom gracefully. -SampleBands computeSampleBands(int w, int h, int deckH) { - SampleBands b; - const int titleH = (std::min)(kTitleHeight, h); - b.title = Rect::ltrb(0, 0, w, titleH); - // Two nav buttons right-anchored in the title band (Browse then Zone). - const int navTop = 2; - const int navBot = (std::max)(navTop, titleH - 2); - const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot); - const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot); - b.navBrowse = browse; - b.navZone = zone; - - int deckTop = h - kPad - deckH; - int clusterTop = deckTop - kClusterHeight - 4; - int heroBottom = clusterTop - 4; - if (heroBottom - titleH < kHeroMinHeight) { - heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below - clusterTop = heroBottom + 4; - deckTop = clusterTop + kClusterHeight + 4; - } - b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom); - b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight); - b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH); - return b; -} - -// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the right -// side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve preview -// button 28 · Mono|Stereo). Draw + hit-test both derive from this ONE formula. -struct ClusterRects { - Rect rootStrip; // remainder-width fenced root strip - Rect preview; // the preview-trigger button - Rect velCell; // the radial preview-velocity knob cell (knob + label band) - Rect velKnob; // the 28px knob square at the cell's top - Rect velLabel; // the 12px label band beneath it - Rect curveBtn; // the mini curve-preview button (opens the popup) -}; -ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono) { - ClusterRects r; - const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; - const int stripBot = stripTop + kStripBandHeight; - const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2; - r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop, - chanMono.x - kPad, curveTop + kCurveBtnSize); - r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop, - r.curveBtn.x - kPad, stripBot); - const int knobLeft = r.velCell.x + (kVelCellW - kDeckKnobSize) / 2; - r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + kDeckKnobSize, - r.velCell.y + kDeckKnobSize); - r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom()); - r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop, - r.velCell.x - kPad, stripBot); - r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot); - return r; -} - -// The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance -// (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula. -Rect zonesStripArea(const Rect& content) { - const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 - return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, - stripTop + kStripBandHeight); -} - -// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the -// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + -// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom() so -// the legend top tracks the strip bottom without re-inlining the strip arithmetic here. -Rect noteEntryFieldsArea(const Rect& content) { - const int stripBottom = zonesStripArea(content).bottom(); - const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) - return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18); -} - -// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal -// segments left-to-right. An out-of-range index yields an empty rect. -Rect noteEntryFieldRect(const Rect& fields, int f) { - if (f < 0 || f > 2 || fields.width <= 0) return Rect{}; - const int segW = fields.width / 3; - const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap - const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW; - return Rect::ltrb(left, fields.y, right, fields.bottom()); -} - -// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + -// the one-line selected-zone legend, running to the content bottom. `bands.content` is the -// Zones mode-content area. Both draw + hit-test use this single formula so they never drift. -Rect zonesControlPanel(const Rect& content) { - const Rect strip = zonesStripArea(content); - const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap - return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad, - content.bottom() - 4); -} - -// FB2 (R11-F2 parity): the Zone panel's per-zone controls render as the SAME knob deck the -// Sample face uses. The deck lays out from the panel top (top-anchored — the Zone panel reads -// top-down, unlike the Sample face's bottom-anchored band), with a column at the panel's right -// reserved for the mini curve-preview button so no deck row starts inside it (layoutDeck places -// the first group of each row unconditionally; collision avoidance relies on the available -// width margin in practice). Both draw + hit-test derive from these two formulas so they never -// drift. -Rect zonesDeckArea(const Rect& content) { - const Rect panel = zonesControlPanel(content); - return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); -} -// The Zone panel's mini curve-preview button (opens the SAME popup editor as the Sample -// cluster's button): the cluster's 28px square, right-anchored at the panel top. -Rect zonesCurveButton(const Rect& content) { - const Rect panel = zonesControlPanel(content); - return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize); -} - -// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and -// the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the -// Box through this ONE formula, so drawn nodes and grabs can never drift apart. -VelocityCurve::Box curveBoxFromRect(const Rect& r) { - return VelocityCurve::Box{r.x + kVelCurveInset, r.y + kVelCurveInset, - (std::max)(0, r.width - 2 * kVelCurveInset), - (std::max)(0, r.height - 2 * kVelCurveInset)}; -} - -// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is -// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored -// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide, -// kChanSegH tall, side by side. -constexpr int kChanSegW = 52; -constexpr int kChanSegH = 18; -struct ChannelToggleRects { Rect mono; Rect stereo; }; -ChannelToggleRects channelToggleRects(const Rect& area) { - const int top = area.y + (area.height - kChanSegH) / 2; - const int right = area.right() - kPad; - const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH); - const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH); - return {mono, stereo}; -} - -// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle map; -// this turns it into LICE calls through the kit's palette roles. LICE's arc convention matches -// param_slider's (angle 0 = 12 o'clock, positive clockwise: point = (cx + r*sin(a), cy - -// r*cos(a)), verified in vendor/WDL lice_arc.cpp) — but LICE takes RADIANS, and drawing the -// 7->5 o'clock sweep THROUGH the top needs a continuous angle span, so the degrees convert as -// (deg - 360) * pi/180, mapping 210..510 onto -150..+150 degrees. One conversion, both arcs. -void drawKnobFace(LICE_IBitmap* bmp, const Rect& knobRect, double value01, - InteractionState st) { - const KnobGeometry kg = computeKnob(knobRect); - if (kg.radius <= 1.0) return; - constexpr double kDegToRad = 3.14159265358979323846 / 180.0; - const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep - const float cx = static_cast(kg.centerX); - const float cy = static_cast(kg.centerY); - const float rOuter = static_cast(kg.radius) - 0.5f; - const bool disabled = (st == InteractionState::Disabled); - const bool hot = (st == InteractionState::Dragging || st == InteractionState::Hover); - - // Face: a filled circle in the cell surface color under the interaction state. - LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(roleColorState(Role::BgCell, st)), - 1.0f, 0, true); - // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). - const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); - const float a1 = static_cast((arc.startDeg + knobSweepDeg(arc) - 360.0) * kDegToRad); - LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, true); - // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / - // dragging, dim when disabled). - const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); - if (v > 0.0) { - const float av = static_cast( - (arc.startDeg + v * knobSweepDeg(arc) - 360.0) * kDegToRad); - const Role valueRole = disabled ? Role::TextDim - : (hot ? Role::AccentHot : Role::AccentPrimary); - LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(roleColor(valueRole)), 1.0f, 0, true); - } - // Needle: from ~35% radius out to the rim at the value's angle. - const KnobPoint tip = knobNeedlePoint(kg, arc, v); - const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); - const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); - const Role needleRole = disabled ? Role::TextDim : Role::TextPrimary; - LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), - static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), - toLice(roleColor(needleRole)), 1.0f, 0, true); -} - -// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface. -// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws -// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip -// so both read as the same spectrum. `stripArea` is the absolute strip rect. -void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { - if (stripArea.width <= 0 || stripArea.height <= 0) return; - const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - const int sx = stripArea.x; - const int sy = stripArea.y; - const int h = stripArea.height; - // A pastel spectral column per key. Each key's local x from keyRect; fill from this key's - // left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet - // backdrop the root/zone marks sit over. S-VIEW-7: OVERLAY the two-tone piano-key pattern — - // naturals (white keys) keep the bright spectral fill; accidentals (C#/D#/F#/G#/A#) get a - // dark bg/base wash over the hue, so a glance reads pitch position as a keyboard without - // counting. The pattern is an OVERLAY (not a keyboard shape) per the spec. - const LICE_pixel darkKey = toLice(roleColor(Role::BgBase)); - for (int n = 0; n <= 127; ++n) { - const Rect k = keyRect(sl, n); - const int x0 = k.x + sx; - const int x1 = (n < 127) ? keyRect(sl, n + 1).x + sx : stripArea.right(); - const int cw = (std::max)(1, x1 - x0); - const KitColor hue = spectralColor(static_cast(n) / 127.0); - LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); - if (!isNaturalKey(n)) { - // Darken the accidental over the hue (a semi-opaque bg/base wash) so the black-key - // pattern reads while the spectral tint still shows through. - LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); - } - } - // Faint per-octave key ticks (hairline role) for orientation. - const LICE_pixel tick = toLice(roleColor(Role::LineHairline)); - for (int n = 0; n <= 127; n += 12) { - const Rect k = keyRect(sl, n); - LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); - } -} - -// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC -// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated. -void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) { - const int sx = stripArea.x; - const int sy = stripArea.y; - const int h = stripArea.height; - const Rect marker = rootMarkerRect(sl, root); - const int mw = (std::max)(2, marker.width); - const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel glow = toLice(roleColor(Role::AccentHot)); - // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). - LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); - LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); -} -} // namespace - -void ReaSamplerEditor::paint(HDC hdc) { - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - if (w <= 0 || h <= 0) return; - - LICE_SysBitmap bmp(w, h); - LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); - - // S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn - // OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the - // modal reads as a sheet layered over the home face (the "picker over the document" grammar). - if (view_ == View::kZone) { - paintZone(&bmp, w, h); - } else { - paintSample(&bmp, w, h); - if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); - } - - // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. - // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn - // LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). - if (dropHintTicks_ > 0) { - const int bannerTop = (std::min)(kTitleHeight, h); - const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); - Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH); - // A transient notice, not the live layer — draw it on the accent-tertiary categorical - // hue with a dark label so it reads as "attention, not action". - fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest); - kitTextCentered(&bmp, banner, - "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.", - Font::Label, Role::BgBase); - } - - BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); -} - -// A small helper: draw the title band with the live readout + the Browse/Zone nav buttons. Shared -// by the Sample face (nav visible) — Browse/Zone draw their own back button in place of the nav. -namespace { -void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) { - fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest); - Rect titleText = Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom()); - kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary); -} -} // namespace - -void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { - // r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP - // ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). - const PerformanceZone deckZone = effectiveSampleZone(); - const std::vector deckDescs = deckGroupDescs(deckZone.play); - const SampleBands bands = - computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - - // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct - // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable - // signal. - std::string title = reasampler::vstPluginName(); // channel-derived (S18) - if (processor_ && processor_->bridge().isConnected()) { - // The instance's OWN loaded state outranks bank availability (pS: the bank is a - // browser source, not the instrument's identity) — a self-contained instance names - // its sound (refs displayName fallback) even when the bank snapshot is empty. - if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; - else if (!selectedId_.empty()) - title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]"; - else if (samples_.empty()) title += " [bank empty]"; - else title += " [pick a capture]"; - } else { - title += " [host: no bridge]"; - } - drawTitleBand(bmp, bands.title, title); - - // Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap - // surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw - // it Active (accent-primary) so it reads as "start here". - const bool empty = selectedId_.empty() && map_.zones.empty(); - { - const KitButtonBox box{toKitBox(bands.navBrowse)}; - const InteractionState st = empty ? InteractionState::Active - : (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest); - drawButton(bmp, box, "Browse", st, /*warn=*/false); - } - { - const KitButtonBox box{toKitBox(bands.navZone)}; - const InteractionState st = - isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Zone", st, /*warn=*/false); - } - - // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing - // at Browse (which is lit above). No hero waveform / controls to draw. - if (empty) { - Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom()); - paintEmptyState(bmp, body); - return; - } - - // Resolve the effective single-capture zone: the picked id's one-zone override when present, - // else the product-default play params (S15-F2 — the single capture is a one-zone map). This - // is the ONE storage site both Sample and Zone edit. - const PerformanceZone& zone = deckZone; - - // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); - const Rect waveArea = bands.hero; - fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); - if (frames > 0 && waveArea.width > 0) { - // FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this - // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact - // partition — extra bins produce no visible change. Clamped to frame count below. - const std::int64_t wantBins = - static_cast((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) * - kWaveformOversample; - const std::size_t bins = - static_cast(wantBins < frames ? wantBins : frames); - const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins); - drawEnvelope(bmp, waveArea, env); - - const SetupMarkers m = pickedMarkers(frames); - if (m.hasLoop && m.loopEnd > m.loopStart) { - const int lx = frameToX(waveArea, frames, m.loopStart); - const int rx = frameToX(waveArea, frames, m.loopEnd); - if (rx > lx) { - LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height, - toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); - } - } - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; - for (int i = 0; i < 3; ++i) { - const int mx = frameToX(waveArea, frames, markerFrames[i]); - const bool loopMarker = (i != 0); - const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; - LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height, - toLice(roleColor(markerRoles[i])), alpha, 0); - } - - // S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero. - paintEnvelopeOverlay(bmp, waveArea, zone, frames); - } else { - kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); - } - - // --- Root + preview cluster (r11: remainder-width root strip, preview button, radial - // velocity knob, mini curve-preview button, channel toggle) ----------------------------- - fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const ClusterRects cr = clusterRects(bands.cluster, chan.mono); - int root = effectiveRoot(); - if (cr.rootStrip.width > 0) { - drawSpectralStrip(bmp, cr.rootStrip); - const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); - drawRootMarker(bmp, cr.rootStrip, sl, root); - } - - // Preview-trigger button (fires the loaded capture at root through the live voice engine). - { - const KitButtonBox box{toKitBox(cr.preview)}; - const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active - : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); - drawButton(bmp, box, "Preview", st, /*warn=*/false); - } - // Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same - // persisted previewVelocity seam. Label swaps to the live value during hover/drag. - { - const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2); - const bool hov = isHovered(HoverKind::kVelKnob, -1); - const InteractionState st = dragging ? InteractionState::Dragging - : (hov ? InteractionState::Hover - : InteractionState::Rest); - drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st); - if (dragging || hov) { - char buf[8]; - snprintf(buf, sizeof(buf), "%d", - static_cast(previewVelocity01() * 127.0 + 0.5)); - kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim); - } else { - kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim); - } - } - // The mini curve-preview button (r11): opens the popup editor. Shared painter with the - // Zone panel's button (FB2 — one grammar on both surfaces). - paintCurveButton(bmp, cr.curveBtn, zone); - // Mono | Stereo output-mode toggle. - { - const bool isStereo = (channelMode_ == ChannelMode::Stereo); - const InteractionState monoState = !isStereo ? InteractionState::Active - : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest); - const InteractionState stereoState = isStereo ? InteractionState::Active - : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest); - fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState); - fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState); - kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary); - kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); - } - - // --- The knob deck (r11: the fenced control groups, bottom-anchored) ------------------- - paintKnobDeck(bmp, bands.deck, zone, deckDescs); - - // --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ---- - if (curvePopupOpen_) paintCurvePopup(bmp, w, h); -} - -void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, - const PerformanceZone& zone, std::int64_t frames) { - if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return; - const double rate = liveSampleRate(); - if (rate <= 0.0) return; - const double totalSeconds = static_cast(frames) / rate; - const std::int64_t startFrame = zone.startPoint.value_or(0); - const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); - const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); - - // Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct - // curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right). - const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); - for (std::size_t i = 1; i < poly.size(); ++i) { - const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x)); - const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x)); - LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); - } - // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- - // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is - // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd - // at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the - // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. - const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); - for (const EnvVertex& v : poly) { - if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; - const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); - const int r = 3; - const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x)); - const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y)); - LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); - } -} - -void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, - const PerformanceZone& zone) { - if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect) - - // The bordered box: a panel surface + hairline border, drawn by palette role. No corner - // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the - // popup is the only host). - fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - - const VelocityCurve::Box box = curveBoxFromRect(r); - if (box.width <= 0 || box.height <= 1) return; - const VelocityCurve& curve = zone.velocityCurve; - - // Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical - // secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity - // and amp -> y mappings both go through the pure module so the trace, the node handles, and - // the hit-test all share one coordinate system. - const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); - int prevX = 0, prevY = 0; - for (int px = 0; px <= box.width; ++px) { - const int cx = box.left + px; - const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity; - const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y; - if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); - prevX = cx; - prevY = cy; - } - - // Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted - // to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor - // has passed kCurveDragOffMargin outside the box — release will delete the node). - const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); - const LICE_pixel handleWarn = toLice(roleColor(Role::Warn)); - // Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin? - const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x && - dragCurveRect_.y == r.y) && - (dragCurX_ < r.x - kCurveDragOffMargin || - dragCurX_ > r.right() + kCurveDragOffMargin || - dragCurY_ < r.y - kCurveDragOffMargin || - dragCurY_ > r.bottom() + kCurveDragOffMargin); - for (std::size_t i = 0; i < curve.points().size(); ++i) { - const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]); - const bool grabbed = (drag_ == DragKind::kCurveNode && - curvePointIndex_ == static_cast(i)); - const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(i)); - // A grabbed node in drag-off territory draws warn to signal "release will delete." - const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn - : (hot ? handleHot : handle); - const int nr = 3; - LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0); - } -} - -void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, - const PerformanceZone& zone, - const std::vector& descs) { - if (deckArea.width <= 0 || deckArea.height <= 0) return; - const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width); - const ZonePlaySeconds& play = zone.play; - const bool isMono = (voiceMode_ == VoiceMode::Mono); - const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); - - // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled - // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. - const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, - bool seg1Active, bool disabled) { - const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); - const InteractionState st0 = - disabled ? InteractionState::Disabled - : (!seg1Active ? InteractionState::Active - : (hov ? InteractionState::Hover : InteractionState::Rest)); - const InteractionState st1 = - disabled ? InteractionState::Disabled - : (seg1Active ? InteractionState::Active - : (hov ? InteractionState::Hover : InteractionState::Rest)); - fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); - fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); - kitTextCentered(bmp, t.seg0, s0, Font::Micro, - disabled ? Role::TextDim - : (!seg1Active ? Role::BgBase : Role::TextPrimary)); - kitTextCentered(bmp, t.seg1, s1, Font::Micro, - disabled ? Role::TextDim - : (seg1Active ? Role::BgBase : Role::TextPrimary)); - }; - - // The knob's short name label (swapped for the live value during hover/drag — r11: no - // third line, no permanent value clutter). - const auto knobName = [](ParamControl c) -> const char* { - switch (c) { - case ParamControl::kAttack: return "Attack"; - case ParamControl::kHold: return "Hold"; - case ParamControl::kDecay: return "Decay"; - case ParamControl::kSustain: return "Sustain"; - case ParamControl::kRelease: return "Release"; - case ParamControl::kTrigFadeIn: return "Fade In"; - case ParamControl::kTrigLength: return "Len %"; - case ParamControl::kTrigFadeOut: return "Fade Out"; - case ParamControl::kKeyTrack: return "Key Trk"; - case ParamControl::kPitchEnvAttack: return "P.Att"; - case ParamControl::kPitchEnvDecay: return "P.Dec"; - case ParamControl::kPitchEnvDepth: return "P.Depth"; - case ParamControl::kVoiceCount: return "Voices"; - case ParamControl::kMasterGain: return "Gain"; - default: return ""; - } - }; - - for (const DeckGroupLayout& g : dl.groups) { - // The fence: a bg/panel box with a hairline border, caption micro-caps left. - fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1, - hairline, 1.0f, 0); - const char* caption = ""; - switch (g.id) { - case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; - case kGroupPitch: caption = "PITCH"; break; - case kGroupPitchEnv: caption = "PITCH ENV"; break; - case kGroupVoice: caption = "VOICE"; break; - case kGroupMaster: caption = "MASTER"; break; - default: break; - } - kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); - - // The compact caption toggle (r11: right-anchored IN the caption row, never full-width). - if (g.captionToggle.id >= 0) { - switch (static_cast(g.captionToggle.id)) { - case ParamControl::kPlayMode: - drawToggle(g.captionToggle, "Gate", "Trigger", - play.playMode == PlayMode::Trigger, false); - break; - case ParamControl::kPitchEngine: - drawToggle(g.captionToggle, "Varisp", "Presrv", - play.pitchEngine == PitchEngine::Preserve, false); - break; - case ParamControl::kPitchEnvEnable: - drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); - break; - case ParamControl::kVoiceMode: - drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); - break; - default: break; - } - } - // The row toggle (VOICE group's Retrig|Legato) — live only in Mono. - if (g.rowToggle.id >= 0) { - drawToggle(g.rowToggle, "Retrig", "Legato", - monoTrigger_ == MonoTrigger::Legato, !isMono); - } - - // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — - // stable geometry (r11). - for (const DeckCellLayout& c : g.cells) { - if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) - const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); - const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); - const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); - const InteractionState st = - disabled ? InteractionState::Disabled - : (dragging ? InteractionState::Dragging - : (hov ? InteractionState::Hover : InteractionState::Rest)); - drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st); - const std::string label = (dragging || hov) - ? deckValueLabel(c.id, zone) - : std::string(knobName(static_cast(c.id))); - kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); - } - } -} - -void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, - const PerformanceZone& zone) { - if (r.width <= 0 || r.height <= 0) return; - // The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone - // panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced - // in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE - // (accent-primary border) while its popup is open, and re-renders live as the popup - // edits the curve (same zone, re-read each paint). - const bool hov = isHovered(HoverKind::kCurveButton, -1); - fillSurface(bmp, toKitBox(r), Role::BgCell, - hov ? InteractionState::Hover : InteractionState::Rest); - const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) - : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0); - const VelocityCurve& curve = zone.velocityCurve; - const int inset = 3; - const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset, - r.height - 2 * inset}; - if (mini.width > 1 && mini.height > 1) { - const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); - int prevX = 0, prevY = 0; - for (int px = 0; px <= mini.width; ++px) { - const int mx = mini.left + px; - const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity; - const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y; - if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true); - prevX = mx; - prevY = my; - } - } -} - -void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { - // The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the - // Sample face stays legible behind it), then the centered sheet. - LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0); - const CurvePopupLayout pl = computeCurvePopup(w, h); - fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1, - pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); - kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); - { - const KitButtonBox box{toKitBox(pl.close)}; - const InteractionState st = isHovered(HoverKind::kPopupClose, -1) - ? InteractionState::Hover - : InteractionState::Rest; - drawButton(bmp, box, "x", st, /*warn=*/false); - } - // The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so - // trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() — - // the picked capture's one-zone site on the Sample face, the selected zone on the Zone - // surface (FB2). - paintVelocityCurve(bmp, pl.curveBox, popupZone()); -} - -PerformanceZone ReaSamplerEditor::popupZone() const { - // The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample - // face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex). - if (view_ == View::kZone && selectedZone_ >= 0 && - selectedZone_ < static_cast(map_.zones.size())) { - return map_.zones[static_cast(selectedZone_)]; - } - return effectiveSampleZone(); -} - -int ReaSamplerEditor::popupZoneIndex() { - // The map_.zones index a popup edit lands on, or -1 when there is no valid target. The - // Zone surface never materializes (the button only shows for an explicit selection); the - // Sample face finds-or-materializes the picked id's one-zone site. - if (view_ == View::kZone) { - return (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) - ? selectedZone_ - : -1; - } - return ensureSampleZone(); -} - -bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { - // The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home - // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash - // click dismiss (outside only when no drag is in flight, per the spec); in-box clicks - // route to the shared curve machinery against popupZoneIndex(); anything else on the - // sheet is swallowed. - if (!curvePopupOpen_) return false; - const CurvePopupLayout pl = computeCurvePopup(w, h); - if (contains(pl.close, x, y)) { - curvePopupOpen_ = false; - invalidate(); - return true; - } - if (contains(pl.curveBox, x, y)) { - const int zi = popupZoneIndex(); - if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y); - return true; - } - if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) { - curvePopupOpen_ = false; - invalidate(); - } - return true; -} - -void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) { - if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; - const VelocityCurve::Box box = curveBoxFromRect(r); - if (box.width <= 0 || box.height <= 1) return; - PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; - - int idx = z.velocityCurve.pointAtPixel(box, x, y); - - // Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once - // (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). - if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { - if (z.velocityCurve.deletePoint(static_cast(idx))) { - selectedZone_ = zoneIndex; - commitAndReload(); - } - return; - } - - // Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight - // ADD (mirror of the other map-editing drags' dragStartMap_ contract). - dragStartMap_ = map_; - - // Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure - // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller - // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the - // caption band — must not add a point; a click there would clamp to velocity 0/127 and - // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab - // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is - // handled above; only the add path is box-gated here. - if (idx < 0) { - const bool inBox = (x >= box.left && x < box.left + box.width && - y >= box.top && y < box.top + box.height); - if (inBox) { - const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y); - idx = static_cast(z.velocityCurve.addPoint(p.velocity, p.amp)); - } - } - - if (idx < 0) return; // ring click with no node hit — nothing to grab - - drag_ = DragKind::kCurveNode; - curvePointIndex_ = idx; - dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base - dragCurveRect_ = r; - dragCurveZone_ = zoneIndex; - dragStartX_ = x; - dragStartY_ = y; - selectedZone_ = zoneIndex; - invalidate(); // live feedback; the commit lands on WM_LBUTTONUP -} - -void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { - // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from - // a bank filter that hides everything. Either way it is the "pick a capture" empty state. - const char* msg = samples_.empty() - ? "No captures in this project yet - capture audio into the bank to play it here." - : "No captures in this bank filter. Choose another bank tab above."; - // Split the area so the primary line sits centered and the S13 ingest affordance sits just - // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept - // discoverable here regardless of whether a drop ever lands on THIS window. - Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2); - Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom()); - kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim); - kitTextCentered(bmp, hint, - "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", - Font::Micro, Role::TextDim); -} - -// The Browse-modal (S-VIEW-5) top-level regions: a title band with a Back button, the search box, -// the browser sub-area (tabs + card grid), and a footer with Cancel / Load-confirm. The picker -// covers the full window (F3 resolved: full-window overlay). Both draw + hit-test derive from this -// single layout so they never drift. `content` is the sub-area layoutBrowser lays out over. -namespace { -struct BrowseModal { - Rect title; - Rect back; // the "Back" title-band button - Rect search; // the type-to-filter box (absolute) - Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin - Rect cancel; // footer Cancel - Rect confirm; // footer Load (confirm) -}; -constexpr int kBrowseFooterH = 30; -BrowseModal computeBrowseModal(int w, int h) { - BrowseModal m; - const int titleH = (std::min)(kTitleHeight, h); - m.title = Rect::ltrb(0, 0, w, titleH); - m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)); - // Search box below the title, spanning the width (searchBoxRect lays it out from 0). - const Rect sb = searchBoxRect(w); - m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height); - const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH); - m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop); - // Footer: Cancel (left) + Load (right). - const int fTop = footerTop + 3; - const int fBot = (std::max)(fTop, h - 3); - m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot); - m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot); - return m; -} -} // namespace - -void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { - // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying - // Sample face with a bg/base wash, then draw the picker opaque on top. - LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); - const BrowseModal bm = computeBrowseModal(w, h); - - // Title band + Back button (returns to Sample, discarding any pending pick). - drawTitleBand(bmp, bm.title, "Browse - pick a capture"); - { - const KitButtonBox box{toKitBox(bm.back)}; - const InteractionState st = - isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Back", st, /*warn=*/false); - } - - // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. - const Rect searchAbs = bm.search; - const InteractionState searchState = - searchFocused_ ? InteractionState::Focus - : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); - if (searchFocused_) { - LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1, - searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); - } - { - std::string sb = searchQuery_.empty() - ? std::string("Search captures...") - : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); - Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom()); - kitText(bmp, sbText, sb.c_str(), Font::Label, - searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); - } - - // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). - const Rect browserArea = bm.content; - const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height); - const int ox = browserArea.x; - const int oy = browserArea.y; - scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); - - const int tabCount = static_cast(banks_.size()) + 1; - for (int i = 0; i < tabCount; ++i) { - Rect t = filterTabRect(bl, tabCount, i); - t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy); - const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; - const bool active = (i == 0) ? activeFilterBankId_.empty() - : (banks_[static_cast(i - 1)].id == activeFilterBankId_); - const InteractionState state = - active ? InteractionState::Active - : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(t), Role::BgCell, state); - kitTextCentered(bmp, t, label.c_str(), Font::Label, - active ? Role::BgBase : Role::TextPrimary); - } - - // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) - // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. - const int bins = thumbBins(bl); - const int cardCount = static_cast(visible_.size()); - const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); - for (int i = vr.first; i < vr.last; ++i) { - Rect content = cardContentRect(bl, i); - Rect thumb = cardThumbnailRect(bl, i); - Rect labelR = cardLabelRect(bl, i); - content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_, - content.right() + ox, content.bottom() + oy - scrollOffset_); - thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_, - thumb.right() + ox, thumb.bottom() + oy - scrollOffset_); - labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_, - labelR.right() + ox, labelR.bottom() + oy - scrollOffset_); - - const SampleChoice& s = visible_[static_cast(i)]; - const bool pending = (s.id == browsePendingId_); - const bool loaded = (s.id == selectedId_); - const InteractionState cardState = - isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; - fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); - const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) - : (loaded ? roleColor(Role::AccentTertiary) - : roleColor(Role::LineHairline)); - LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1, - toLice(cardBorder), 1.0f, 0); - drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); - - std::string caption = s.displayName.empty() ? s.id : s.displayName; - Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2); - Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom()); - kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); - std::string badge; - if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); - else if (s.key) badge = *s.key; - else badge = "root -"; - kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); - } - - // Scrollbar thumb. - { - const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); - if (thumb.height > 0) { - const bool dragging = (drag_ == DragKind::kScrollThumb); - const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); - LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height, - toLice(tc), 0.8f, 0); - } - } - - if (visible_.empty()) paintEmptyState(bmp, browserArea); - - // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert - // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. - Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h); - fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); - { - const KitButtonBox box{toKitBox(bm.cancel)}; - const InteractionState st = - isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Cancel", st, /*warn=*/false); - } - { - const KitButtonBox box{toKitBox(bm.confirm)}; - const bool armed = !browsePendingId_.empty(); - const InteractionState st = armed - ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) - : InteractionState::Rest; - drawButton(bmp, box, "Load", st, /*warn=*/false); - } -} - -// The Zone-view (S-VIEW-8) content area: the whole window below the title band. -namespace { -Rect zoneContentArea(int w, int h) { - const int titleH = (std::min)(kTitleHeight, h); - return Rect::ltrb(0, titleH, w, h); -} -} // namespace - -void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { - // Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns - // to the Sample home on close. - const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h)); - drawTitleBand(bmp, title, "Zone - keyboard map"); - { - const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom() - 2)); - const KitButtonBox box{toKitBox(back)}; - const InteractionState st = - isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Back", st, /*warn=*/false); - } - - const Rect content = zoneContentArea(w, h); - const int pad = 8; - - // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip - // with one bar per zone. Delete is a small × on the selected zone (keystroke also). - Rect addR = Rect::ltrb(content.x + pad, content.y + 4, content.x + pad + 96, - content.y + 4 + 20); - { - const KitButtonBox box{toKitBox(addR)}; - const InteractionState state = - isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false); - } - - Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); - if (selectedZone_ >= 0) { - const KitButtonBox box{toKitBox(delR)}; - const InteractionState state = - isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest; - // Deleting a zone is not a byte-destroying act (no file removed — the bank is - // read-only here), so it is a normal button, not `warn`. - drawButton(bmp, box, "Delete", state, /*warn=*/false); - } - - // The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per - // zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which - // zone is live"); the rest take the categorical secondary hue at low alpha. - const Rect stripArea = zonesStripArea(content); - drawSpectralStrip(bmp, stripArea); - const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - const int sx = stripArea.x; - const int sy = stripArea.y; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - const PerformanceZone& z = map_.zones[static_cast(i)]; - Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); - const int bw = (std::max)(2, bar.width); - const bool sel = (i == selectedZone_); - if (sel) { - // Static glow halo behind the live zone, then the crisp accent-primary bar. - LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height, - toLice(roleColor(Role::AccentHot)), 0.30f, 0); - LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, - toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); - } else { - LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, - toLice(roleColor(Role::AccentSecondary)), 0.55f, 0); - } - } - - // A one-line legend of the selected zone below the strip, with three click-to-type numeric - // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it - // (entryField_) and typed text commits via parseNoteEntry on Enter. - const int legendTop = stripArea.bottom() + 8; - Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18); - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()), - sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{}, - z.sampleId) - .c_str(), - Font::Label, Role::TextPrimary); - // Three fields laid out left-to-right after the sample label. A focused field lifts to - // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter. - const Rect fields = noteEntryFieldsArea(content); - const char* names[3] = {"Low", "High", "Root"}; - const std::string vals[3] = { - noteLabel(z.lowNote), noteLabel(z.highNote), - z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; - for (int f = 0; f < 3; ++f) { - const Rect fr = noteEntryFieldRect(fields, f); - const bool editing = (entryField_ == f); - fillSurface(bmp, toKitBox(fr), Role::BgCell, - editing ? InteractionState::Focus : InteractionState::Rest); - const KitColor border = - editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1, - toLice(border), 1.0f, 0); - std::string cap = std::string(names[f]) + ": " + - (editing ? (entryText_ + "_") : vals[f]); - kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(), - Font::ValueMono, Role::TextPrimary); - } - } else if (map_.zones.empty()) { - kitText(bmp, infoR, - "No zones. Add Zone maps the picked capture across the keyboard.", - Font::Label, Role::TextDim); - } - - // The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck + - // curve-preview-button/popup grammar as the Sample face — one control language over the one - // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only - // the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live - // on the Sample deck only. - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play)); - paintCurveButton(bmp, zonesCurveButton(content), z); - } - - // The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST — - // the same modal grammar as the Sample face. - if (curvePopupOpen_) paintCurvePopup(bmp, w, h); -} - -// --- Hover resolution (Phase L, L3) ------------------------------------------ -// -// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an -// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline). -// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only -// the frequently-touched interactive surfaces light on hover; a purely decorative region -// resolves to kNone (clearing any prior hover). Windows-only. -void ReaSamplerEditor::resolveHover(int x, int y) { - HoverTarget h; // kNone by default - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int hgt = cr.bottom - cr.top; - - if (view_ == View::kBrowse) { - const BrowseModal bm = computeBrowseModal(w, hgt); - if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1}; - else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1}; - else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1}; - else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1}; - else { - const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); - const int bx = x - bm.content.x; - const int by = y - bm.content.y; - const int tabCount = static_cast(banks_.size()) + 1; - const int tab = filterTabHitTest(bl, tabCount, bx, by); - const int card = (tab >= 0) - ? -1 - : cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); - if (tab >= 0) h = {HoverKind::kFilterTab, tab}; - else if (card >= 0) h = {HoverKind::kCard, card}; - } - } else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2) - const CurvePopupLayout pl = computeCurvePopup(w, hgt); - if (contains(pl.close, x, y)) { - h = {HoverKind::kPopupClose, -1}; - } else if (contains(pl.curveBox, x, y)) { - // A curve node under the pointer lights accent-hot. - const int idx = - popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); - if (idx >= 0) h = {HoverKind::kCurveNode, idx}; - } - } else if (view_ == View::kZone) { - const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)); - const Rect content = zoneContentArea(w, hgt); - Rect addR = Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96, content.y + 4 + 20); - Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); - if (contains(back, x, y)) { - h = {HoverKind::kBack, -1}; - } else if (contains(addR, x, y)) { - h = {HoverKind::kAddZone, -1}; - } else if (selectedZone_ >= 0 && contains(delR, x, y)) { - h = {HoverKind::kDeleteZone, -1}; - } else if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - // FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's - // hover grammar — knobs light + swap label->value). - if (contains(zonesCurveButton(content), x, y)) { - h = {HoverKind::kCurveButton, -1}; - } else { - const ZonePlaySeconds& play = - map_.zones[static_cast(selectedZone_)].play; - const Rect deckArea = zonesDeckArea(content); - const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, - deckArea.y, deckArea.width); - const DeckHit dh = hitTestDeck(dl, x, y); - if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; - } - } - } else { // Sample view (home, r11 recomposition) - const PerformanceZone zone = effectiveSampleZone(); - const std::vector descs = deckGroupDescs(zone.play); - const SampleBands bands = - computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad)); - if (contains(bands.navBrowse, x, y)) { - h = {HoverKind::kNavBrowse, -1}; - } else if (contains(bands.navZone, x, y)) { - h = {HoverKind::kNavZone, -1}; - } else if (selectedId_.empty() && map_.zones.empty()) { - // Empty state — no interactive surfaces beyond the nav. - } else { - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const ClusterRects cr = clusterRects(bands.cluster, chan.mono); - if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1}; - else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1}; - else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1}; - else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; - else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; - else if (contains(bands.deck, x, y)) { - // A deck knob/toggle under the pointer: knobs light + swap label->value. - const DeckLayout dl = - layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width); - const DeckHit dh = hitTestDeck(dl, x, y); - if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; - } - } - } - - if (h != hover_) { - hover_ = h; - invalidate(); - } -} - -// --- Input: the drag-state machine ------------------------------------------- - -void ReaSamplerEditor::onMouseDown(int x, int y) { - if (!processor_) return; - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - - // ---- Browse modal (S-VIEW-5): pick + confirm/cancel over the Sample face ---- - if (view_ == View::kBrowse) { - const BrowseModal bm = computeBrowseModal(w, h); - if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { - // Cancel/Back: discard the pending pick, return to Sample unchanged. - browsePendingId_.clear(); - searchFocused_ = false; - view_ = View::kSample; - invalidate(); - return; - } - if (contains(bm.confirm, x, y)) { - // Load: commit the pending pick (if any) into the loaded selection + reload, then Sample. - if (!browsePendingId_.empty()) { - loadSelection(browsePendingId_); - } - browsePendingId_.clear(); - searchFocused_ = false; - view_ = View::kSample; - invalidate(); - return; - } - if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } - searchFocused_ = false; - - const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); - const int bx = x - bm.content.x; - const int by = y - bm.content.y; - const int tabCount = static_cast(banks_.size()) + 1; - const int tab = filterTabHitTest(bl, tabCount, bx, by); - if (tab >= 0) { - activeFilterBankId_ = (tab == 0) ? std::string() - : banks_[static_cast(tab - 1)].id; - rebuildVisible(); - invalidate(); - return; - } - const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); - if (thumb.height > 0 && - contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y, - thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) { - drag_ = DragKind::kScrollThumb; - dragStartY_ = y; - dragStartScrollOffset_ = scrollOffset_; - return; - } - const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); - if (card >= 0) { - // Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card - // is the load accelerator (commit + dismiss). Browse never loads on a single click. - const std::string id = visible_[static_cast(card)].id; - if (lastBrowseClickCard_ == card && browsePendingId_ == id) { - loadSelection(id); - browsePendingId_.clear(); - lastBrowseClickCard_ = -1; - searchFocused_ = false; - view_ = View::kSample; - invalidate(); - } else { - browsePendingId_ = id; - lastBrowseClickCard_ = card; - invalidate(); - } - return; - } - lastBrowseClickCard_ = -1; - return; - } - - // ---- Sample home (S-VIEW-2 / r11) ---- - if (view_ == View::kSample) { - // r11 curve popup: while open the sheet is modal — it owns every left-click. - if (handlePopupMouseDown(w, h, x, y)) return; - - const PerformanceZone probeZone = effectiveSampleZone(); - const std::vector deckDescs = deckGroupDescs(probeZone.play); - const SampleBands bands = - computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - if (contains(bands.navBrowse, x, y)) { - // Open the Browse modal; seed its pending pick from the loaded id so the current - // capture reads as pre-selected. - browsePendingId_ = selectedId_; - lastBrowseClickCard_ = -1; - view_ = View::kBrowse; - invalidate(); - return; - } - if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; } - if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only - - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const ClusterRects cr = clusterRects(bands.cluster, chan.mono); - - // Preview-trigger button: fire the loaded capture at its root through the voice engine - // (momentary — note-on on press, note-off on release). - if (contains(cr.preview, x, y)) { - const int note = effectiveRoot(); - if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); - previewingNote_ = note; - processor_->previewNoteOn(note); - invalidate(); - return; - } - // Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself - // never jumps the value (FA4); the delta from the grab point maps via knobDragValue. - if (contains(cr.velCell, x, y)) { - drag_ = DragKind::kDeckKnob; - dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) - dragParamZone_ = -1; - dragKnobStartValue_ = previewVelocity01(); - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - return; - } - // The mini curve-preview button: summon the popup editor. - if (contains(cr.curveBtn, x, y)) { - curvePopupOpen_ = true; - invalidate(); - return; - } - // Channel toggle. - if (contains(chan.mono, x, y)) { - channelMode_ = ChannelMode::Mono; - processor_->setChannelMode(ChannelMode::Mono); - invalidate(); - return; - } - if (contains(chan.stereo, x, y)) { - channelMode_ = ChannelMode::Stereo; - processor_->setChannelMode(ChannelMode::Stereo); - invalidate(); - return; - } - - // The knob deck (r11): toggles commit at once (a discrete, final edit — the slider - // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its - // clicks (no fall-through to the hero/markers). - if (contains(bands.deck, x, y)) { - const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y, - bands.deck.width); - const DeckHit hit = hitTestDeck(dl, x, y); - if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { - switch (static_cast(hit.id)) { - case ParamControl::kVoiceMode: { - // Processor-side per-instance param: live setter (engine rebuild via - // the drain-slot swap — tails survive), local snapshot in step. - const VoiceMode m = - (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly; - if (m != voiceMode_) { - voiceMode_ = m; - processor_->setVoiceMode(m); - } - invalidate(); - break; - } - case ParamControl::kMonoTrigger: { - if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly - const MonoTrigger t = - (hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; - if (t != monoTrigger_) { - monoTrigger_ = t; - processor_->setMonoTrigger(t); - } - invalidate(); - break; - } - default: { - // Zone-param toggles (play mode / pitch engine / pitch-env enable): - // materialize the one-zone site, apply, commit. - const int zi = ensureSampleZone(); - if (zi >= 0) { - applyZoneControl(zi, hit.id, 0.0, hit.segment); - selectedZone_ = zi; - commitAndReload(); - } - break; - } - } - return; - } - if (hit.kind == DeckHitKind::Knob) { - // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off. - const bool pitchEnvKnob = - hit.id == static_cast(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(ParamControl::kPitchEnvDepth); - if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return; - if (hit.id == static_cast(ParamControl::kVoiceCount) || - hit.id == static_cast(ParamControl::kMasterGain)) { - // Processor-side knobs: transient live writes, no map edit, no reload. - drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragParamZone_ = -1; - dragKnobStartValue_ = deckControlNorm(hit.id, probeZone); - } else { - // Zone-param knobs: live-drag the map, commit on release. - const int zi = ensureSampleZone(); - if (zi < 0) return; - drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragParamZone_ = zi; - selectedZone_ = zi; - dragStartMap_ = map_; - dragKnobStartValue_ = - deckControlNorm(hit.id, map_.zones[static_cast(zi)]); - } - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - } - return; - } - - // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); - const Rect waveArea = bands.hero; - if (frames > 0) { - const double rate = liveSampleRate(); - if (rate > 0.0) { - const PerformanceZone zone = effectiveSampleZone(); - const std::int64_t startFrame = zone.startPoint.value_or(0); - const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); - const double totalSeconds = static_cast(frames) / rate; - const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y); - if (nh.hit) { - drag_ = DragKind::kEnvNode; - envNode_ = nh.node; - dragStartX_ = x; - dragStartY_ = y; - dragStartEnv_ = env; - dragSampleFrames_ = frames; - dragStartFrame_ = startFrame; - dragStartMap_ = map_; - return; // node moves once the cursor drags - } - } - const SetupMarkers m = pickedMarkers(frames); - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); - if (hit >= 0) { - drag_ = DragKind::kWaveMarker; - waveMarker_ = static_cast(hit); - dragStartX_ = x; - dragStartMarkers_ = m; - dragSampleFrames_ = frames; - dragStartMap_ = map_; - return; - } - } - - // Fenced root strip: grab the root marker (remainder-width since r11). - if (cr.rootStrip.width > 0) { - const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); - const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); - if (note >= 0) { - drag_ = DragKind::kRootMarker; - dragStartX_ = x; - dragStartRoot_ = note; - dragStartMap_ = map_; - onMouseMove(x, y); // apply the click as the first delta==0 set - return; - } - } - return; - } - - // ---- Zone surface (S-VIEW-8 / FB2) ---- - // The curve popup is modal over the Zone surface too (FB2) — it owns every click while - // open, checked before every Zone affordance (incl. Back). - if (handlePopupMouseDown(w, h, x, y)) return; - const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)); - if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } - const Rect content = zoneContentArea(w, h); - const int pad = 8; - Rect addR = Rect::ltrb(content.x + pad, content.y + 4, content.x + pad + 96, - content.y + 4 + 20); - if (contains(addR, x, y)) { - // Add a narrow default zone for the picked capture (or the first visible sample as a - // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id - // already exists (pre-fix bleed survivor), select it rather than appending a duplicate - // (mirrors the upsert the root-marker drag path already performs). - // NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped - // to [0,127]) so the new zone is immediately "authored" (narrow) and survives - // reconcileSingleCaptureZones without being treated as a Sample-face full-range zone. - std::string seed = !selectedId_.empty() ? selectedId_ - : (!visible_.empty() ? visible_.front().id : std::string()); - if (seed.empty()) return; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - const PerformanceZone& z = map_.zones[static_cast(i)]; - if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { - selectedZone_ = i; - invalidate(); - return; - } - } - // Look up the seed's root note from the browser list (absent root defaults to 60). - int seedRoot = 60; - for (const SampleChoice& sc : samples_) { - if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; } - } - const int lo = (std::max)(0, seedRoot - 6); - const int hi = (std::min)(127, seedRoot + 5); - PerformanceZone z; - z.sampleId = seed; - z.lowNote = lo; - z.highNote = hi; - map_.zones.push_back(z); - selectedZone_ = static_cast(map_.zones.size()) - 1; - commitAndReload(); - return; - } - Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); - if (selectedZone_ >= 0 && contains(delR, x, y)) { - map_.zones.erase(map_.zones.begin() + selectedZone_); - selectedZone_ = -1; - commitAndReload(); - return; - } - - // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the - // selected zone's root. - const Rect stripArea = zonesStripArea(content); - const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - const int lx = x - stripArea.x; - const int ly = y - stripArea.y; - - std::vector lows, highs; - lows.reserve(map_.zones.size()); - highs.reserve(map_.zones.size()); - for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } - const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), - highs.empty() ? nullptr : highs.data(), - static_cast(map_.zones.size()), lx, ly); - if (hit.zoneIndex >= 0) { - selectedZone_ = hit.zoneIndex; - const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; - dragStartX_ = x; - dragStartLow_ = z.lowNote; - dragStartHigh_ = z.highNote; - dragStartMap_ = map_; - switch (hit.grab) { - case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; - case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; - case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; - default: drag_ = DragKind::kNone; break; - } - invalidate(); - return; - } - // A bare key-click inside the strip sets the selected zone's root override. - if (contains(stripArea, x, y) && selectedZone_ >= 0 && - selectedZone_ < static_cast(map_.zones.size())) { - const int note = keyAtPoint(sl, lx, ly); - if (note >= 0) { - map_.zones[static_cast(selectedZone_)].rootOverride = note; - commitAndReload(); - } - return; - } - - // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a - // zone is selected. entryText_ starts empty (the user types the full value). - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const Rect fields = noteEntryFieldsArea(content); - for (int f = 0; f < 3; ++f) { - if (contains(noteEntryFieldRect(fields, f), x, y)) { - entryField_ = f; - entryText_.clear(); - invalidate(); - return; - } - } - } - entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry - - // The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the - // SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected - // (the Zone surface has no single-capture fallback — that lives on the Sample face). - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - if (contains(zonesCurveButton(content), x, y)) { - curvePopupOpen_ = true; - invalidate(); - return; - } - const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; - const Rect deckArea = zonesDeckArea(content); - const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y, - deckArea.width); - const DeckHit hit = hitTestDeck(dl, x, y); - if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { - // Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete, - // final edit committed at once (the deck precedent). No per-instance ids reach - // here — VOICE/MASTER are not in the zone group set. - applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment); - commitAndReload(); - return; - } - if (hit.kind == DeckHitKind::Knob) { - // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the - // Sample deck's guard, mirrored. - const bool pitchEnvKnob = - hit.id == static_cast(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(ParamControl::kPitchEnvDepth); - if (pitchEnvKnob && !play.pitchEnv.enabled) return; - // GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release. - drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragParamZone_ = selectedZone_; - dragStartMap_ = map_; - dragKnobStartValue_ = deckControlNorm( - hit.id, map_.zones[static_cast(selectedZone_)]); - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - } - } -} - -void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) { - if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; - PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; - if (id == static_cast(ParamControl::kKeyTrack)) { - // keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1. - z.keyTrack = clamp01(value) * kKeyTrackMax; - } else { - applyControl(id, z.play, value, segment); - } -} - -void ReaSamplerEditor::onMouseMove(int x, int y) { - if (drag_ == DragKind::kNone) return; - dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn) - dragCurY_ = y; - RECT rc{}; - GetClientRect(childHwnd_, &rc); - const int w = rc.right - rc.left; - const int h = rc.bottom - rc.top; - const int dx = x - dragStartX_; - - if (drag_ == DragKind::kDeckKnob) { - // r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from - // the value at grab (up = increase), so the value tracks relative motion and never - // jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. - const int dy = y - dragStartY_; - applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); - invalidate(); - return; - } - - // r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted - // below the kDeckKnob early-return — that branch uses neither deckDescs nor bands. - const std::vector deckDescs = deckGroupDescs(effectiveSampleZone().play); - const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - - if (drag_ == DragKind::kRootMarker) { - // The fenced root strip on the Sample cluster band. Setting the root materializes a - // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — - // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const Rect stripArea = clusterRects(bands.cluster, chan.mono).rootStrip; - const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - const int note = resolveDragNote(sl, dragStartRoot_, dx); - bool found = false; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - PerformanceZone& z = map_.zones[static_cast(i)]; - if (z.sampleId == selectedId_) { - z.rootOverride = note; - selectedZone_ = i; - found = true; - break; - } - } - if (!found) { - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.rootOverride = note; - map_.zones.push_back(z); - selectedZone_ = static_cast(map_.zones.size()) - 1; - } - invalidate(); // live feedback; the commit lands on WM_LBUTTONUP - return; - } - - if (drag_ == DragKind::kEnvNode) { - // S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through - // the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the - // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_) - // so the delta is absolute. Materialize the zone if needed (mirror of the marker path). - const std::int64_t frames = dragSampleFrames_; - const double rate = liveSampleRate(); - if (frames <= 0 || rate <= 0.0) return; - const double totalSeconds = static_cast(frames) / rate; - const int dy = y - dragStartY_; - const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero, - totalSeconds, envClampBounds(), dx, dy); - const int zi = ensureSampleZone(); - if (zi >= 0) { - unpackEnvelope(edited, frames, dragStartFrame_, - map_.zones[static_cast(zi)].play); - selectedZone_ = zi; - } - invalidate(); // live feedback; commit on WM_LBUTTONUP - return; - } - - if (drag_ == DragKind::kCurveNode) { - // S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure - // inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + - // box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the - // commit lands on WM_LBUTTONUP. - if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast(map_.zones.size())) return; - if (curvePointIndex_ < 0) return; - const int dy = y - dragStartY_; - map_.zones[static_cast(dragCurveZone_)].velocityCurve = - VelocityCurve::resolvePointDrag(dragStartCurve_, - static_cast(curvePointIndex_), - curveBoxFromRect(dragCurveRect_), dx, dy); - invalidate(); - return; - } - - if (drag_ == DragKind::kWaveMarker) { - // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap - // it against the decoded PCM, apply the inter-marker clamps, and write the override live. - const Rect waveArea = bands.hero; - const std::int64_t frames = dragSampleFrames_; - if (frames <= 0) return; - - // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). - const int idx = static_cast(waveMarker_); - const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, - dragStartMarkers_.loopEnd}; - std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); - - // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware - // requirement). Pure over the cached mono frames — no host types, no file I/O. - const std::vector& pcm = monoPcmFor(selectedId_); - if (!pcm.empty()) { - newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), - newFrame); - } - - // Build the edited marker set from the snapshot, moving only the grabbed marker, then - // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. - SetupMarkers m = dragStartMarkers_; - if (waveMarker_ == WaveMarker::kStart) { - m.start = newFrame; - } else if (waveMarker_ == WaveMarker::kLoopStart) { - m.loopStart = (std::min)(newFrame, m.loopEnd); - m.hasLoop = true; - } else { // kLoopEnd - m.loopEnd = (std::max)(newFrame, m.loopStart); - m.hasLoop = true; - } - if (m.start < 0) m.start = 0; - if (m.start > frames - 1) m.start = frames - 1; - - // Upsert the override on the picked id (mirror of the root-marker path); commit lands on - // release, this is live feedback. Set selectedZone_ so the control panel stays visible - // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty - // map hides controls after the first marker drag on the single-capture face). - selectedZone_ = upsertPickedOverride(m); - invalidate(); - return; - } - - if (drag_ == DragKind::kScrollThumb) { - // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag - // only happens in the Browse modal (the sole card grid). The visible-card window recomputes - // at paint from scrollOffset_. - const int dyThumb = y - dragStartY_; - const BrowseModal bm = computeBrowseModal(w, h); - const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); - scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), - dragStartScrollOffset_, dyThumb); - invalidate(); - return; - } - - // Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached - // in the Zone surface where selectedZone_ is set + the strip lives under its content area. - if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const Rect stripArea = zonesStripArea(zoneContentArea(w, h)); - const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - if (drag_ == DragKind::kZoneLow) { - z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); - } else if (drag_ == DragKind::kZoneHigh) { - z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); - } else if (drag_ == DragKind::kZoneBody) { - // Move the whole span: apply the SAME delta to both edges so the span is preserved, - // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). - const int newLow = resolveDragNote(sl, dragStartLow_, dx); - const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); - const int span = dragStartHigh_ - dragStartLow_; - if (newLow < 0) { z.lowNote = 0; z.highNote = span; } - else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } - else { z.lowNote = newLow; z.highNote = newHigh; } - } - invalidate(); -} - -void ReaSamplerEditor::onMouseUp(int x, int y) { - // Release a held preview note first (the preview button is a momentary key: note-off on up). - // This runs regardless of drag state — the preview press does not start a drag. - if (previewingNote_ >= 0) { - if (processor_) processor_->previewNoteOff(previewingNote_); - previewingNote_ = -1; - invalidate(); - } - if (drag_ == DragKind::kNone) return; - const DragKind kind = drag_; - const int paramId = dragParamId_; - const int curveIdx = curvePointIndex_; - const int curveZone = dragCurveZone_; - const Rect curveRect = dragCurveRect_; - drag_ = DragKind::kNone; - dragParamId_ = -1; - dragParamZone_ = -1; - curvePointIndex_ = -1; - dragCurveZone_ = -1; - // A scrollbar drag is transient UI (no map change), and the processor-side knobs (the - // preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that - // don't reload the instrument via the map path. Master gain is an atomic the audio thread - // reads directly. Voice count: the label/needle tracks live during the drag but the engine - // rebuild (setVoiceCount) fires ONCE here on release — not per integer step. - const bool deckTransient = - kind == DragKind::kDeckKnob && - (paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || - paramId == static_cast(ParamControl::kMasterGain)); - if (kind == DragKind::kScrollThumb || deckTransient) { - // Commit the voice count now that the drag is complete (one rebuild per full drag). - if (deckTransient && processor_ && - paramId == static_cast(ParamControl::kVoiceCount)) - processor_->setVoiceCount(voiceCount_); - invalidate(); - return; - } - // S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the - // dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain - // move — its amp keeps the last clamped drag value). - if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 && - curveZone < static_cast(map_.zones.size())) { - const bool off = x < curveRect.x - kCurveDragOffMargin || - x > curveRect.right() + kCurveDragOffMargin || - y < curveRect.y - kCurveDragOffMargin || - y > curveRect.bottom() + kCurveDragOffMargin; - if (off) { - map_.zones[static_cast(curveZone)].velocityCurve.deletePoint( - static_cast(curveIdx)); - hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint - } - } - commitAndReload(); -} - -void ReaSamplerEditor::onMouseRDown(int x, int y) { - // r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete - // affordance; Alt-click and drag-off remain as landed alternates. Commits immediately - // through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint - // right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the - // Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) — - // and never during an in-flight left drag. - if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return; - if (drag_ != DragKind::kNone) return; - RECT rc{}; - GetClientRect(childHwnd_, &rc); - const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); - if (!contains(pl.curveBox, x, y)) return; - // Hit-test first (read-only, via popupZone) so a right-click that lands between nodes - // does not materialize an uncommitted zone in map_. Materialize only on an actual hit. - const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); - const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y); - if (idx < 0) return; - const int zi = popupZoneIndex(); - if (zi < 0) return; - PerformanceZone& z = map_.zones[static_cast(zi)]; - if (z.velocityCurve.deletePoint(static_cast(idx))) { - selectedZone_ = zi; - hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node - commitAndReload(); - } -} - -void ReaSamplerEditor::onMouseWheel(int delta) { - // Browser scroll (only in the Browse modal — the sole card grid). One wheel notch - // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive - // delta (wheel up) scrolls toward the top (smaller offset). - if (view_ != View::kBrowse) return; - const int rows = delta / 120; - if (rows == 0) return; - scrollOffset_ -= rows * kBrowserCardHeight; - if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content - invalidate(); -} - -void ReaSamplerEditor::onSearchChar(unsigned int ch) { - // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face - // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search - // cannot hold focus under it). - if (curvePopupOpen_ && ch == 27) { - curvePopupOpen_ = false; - invalidate(); - return; - } - - // S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes - // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when - // focused, owns the keystrokes). - if (view_ == View::kZone && entryField_ >= 0) { - if (ch == 13) { // Enter: parse + commit - if (auto note = parseNoteEntry(entryText_)) { - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); - else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); - else z.rootOverride = *note; - commitAndReload(); - } - } - entryField_ = -1; - entryText_.clear(); - invalidate(); - } else if (ch == 27) { // Escape cancels - entryField_ = -1; - entryText_.clear(); - invalidate(); - } else if (ch == 8) { // backspace - if (!entryText_.empty()) entryText_.pop_back(); - invalidate(); - } else if (ch >= 32 && ch < 127) { - entryText_.push_back(static_cast(ch)); - invalidate(); - } - return; - } - - // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace - // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). - if (view_ != View::kBrowse || !searchFocused_) return; - if (ch == 8) { // backspace - if (!searchQuery_.empty()) searchQuery_.pop_back(); - } else if (ch == 27) { // escape clears + defocuses - searchQuery_.clear(); - searchFocused_ = false; - } else if (ch >= 32 && ch < 127) { - searchQuery_.push_back(static_cast(ch)); - } else { - return; // ignore other control chars - } - scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list - rebuildVisible(); - invalidate(); -} - -void ReaSamplerEditor::onFilesDropped(int droppedCount) { - // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact - // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff - // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a - // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing - // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs - // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. - (void)droppedCount; // count is informational; the banner text is drop-count-agnostic - dropHintTicks_ = 6; -#ifdef _WIN32 - invalidate(); -#endif -} - -LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, - LPARAM lParam) { - auto* self = - reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); - switch (msg) { - case WM_PAINT: { - PAINTSTRUCT ps{}; - HDC hdc = BeginPaint(hwnd, &ps); - if (self) self->paint(hdc); - EndPaint(hwnd, &ps); - return 0; - } - case WM_LBUTTONDOWN: - if (self) { - SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child - SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) - self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - } - return 0; - case WM_MOUSEMOVE: - if (self) { - const int mx = GET_X_LPARAM(lParam); - const int my = GET_Y_LPARAM(lParam); - // Hover feedback (Phase L, L3): resolve the element under the pointer and - // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover - // clears when the pointer leaves the child (TrackMouseEvent is one-shot). - if (!self->mouseTracking_) { - TRACKMOUSEEVENT tme{}; - tme.cbSize = sizeof(tme); - tme.dwFlags = TME_LEAVE; - tme.hwndTrack = hwnd; - TrackMouseEvent(&tme); - self->mouseTracking_ = true; - } - // While a drag is in flight the drag owns the surface; skip hover resolution - // (a hover repaint mid-drag would fight the live drag feedback). - if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my); - self->onMouseMove(mx, my); - } - return 0; - case WM_MOUSELEAVE: - if (self) { - self->mouseTracking_ = false; - if (self->hover_.kind != HoverKind::kNone) { - self->hover_ = HoverTarget{}; - self->invalidate(); - } - } - return 0; - case WM_MOUSEWHEEL: - // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. - if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); - return 0; - case WM_CHAR: - // S12 type-to-filter search keystrokes (only acted on when the search box is focused). - if (self) self->onSearchChar(static_cast(wParam)); - return 0; - case WM_GETDLGCODE: - // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). - return DLGC_WANTCHARS | DLGC_WANTARROWS; - case WM_LBUTTONUP: - if (self) { - self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - ReleaseCapture(); - } - return 0; - case WM_RBUTTONDOWN: - // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). - // Routed explicitly (the child wndproc historically handled only left-button). - if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_RBUTTONUP: - return 0; // claimed so the pair never reaches DefWindowProc (no context menu) - case WM_CAPTURECHANGED: - // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its - // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset - // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. - // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. - if (self) { - // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture - // loss otherwise leaves the momentary-key voice hung with no note-off. - if (self->previewingNote_ >= 0) { - if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); - self->previewingNote_ = -1; - self->invalidate(); - } - if (self->drag_ != DragKind::kNone) { - // A scrollbar drag + the processor-side deck knobs (preview velocity -2 / - // voice count / master gain) are transient (no map mutation; dragStartMap_ - // not snapshotted) — reset drag state only, never touch map_. Every - // map-editing drag rolls its live mutation back to the snapshot. - const bool transient = self->drag_ == DragKind::kScrollThumb || - (self->drag_ == DragKind::kDeckKnob && - (self->dragParamId_ == -2 || - self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || - self->dragParamId_ == static_cast(ParamControl::kMasterGain))); - if (!transient) self->map_ = self->dragStartMap_; - self->drag_ = DragKind::kNone; - self->dragParamId_ = -1; - self->dragParamZone_ = -1; - self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) - self->dragCurveZone_ = -1; - self->invalidate(); - } - } - return 0; - case WM_DROPFILES: { - // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT - // read/ingest the paths (the instrument never ingests — the relay to the extension is - // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. - HDROP drop = reinterpret_cast(wParam); - const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); - DragFinish(drop); - if (self) self->onFilesDropped(static_cast(count)); - return 0; - } - case WM_TIMER: - if (self && wParam == kSyncTimerId) self->onSyncTimer(); - return 0; - case WM_ERASEBKGND: - return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase - default: - return DefWindowProcW(hwnd, msg, wParam, lParam); - } -} - -#else // non-Windows: not a build target (D5), but keep the TU compilable. - -void ReaSamplerEditor::attachedToParent() {} -void ReaSamplerEditor::removedFromParent() {} -tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - return CPluginView::onSize(newSize); -} - -#endif // _WIN32 - -} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp deleted file mode 100644 index 4cce960..0000000 --- a/src/vst/reasampler_processor.cpp +++ /dev/null @@ -1,1168 +0,0 @@ -#include "core/namespaces.h" -// reasampler_processor.cpp — see reasampler_processor.h. - -#include "reasampler_processor.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "pluginterfaces/base/ibstream.h" -#include "pluginterfaces/vst/ivstaudioprocessor.h" -#include "pluginterfaces/vst/ivstevents.h" -#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) -#include "pluginterfaces/vst/vstspeaker.h" - -#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) -#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision -#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) -#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) -#include "reasampler_editor.h" -#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) -#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) -#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) - -using namespace Steinberg; -using namespace Steinberg::Vst; - -namespace reasampler::vst { - -namespace { - -// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is -// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather -// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice -// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the -// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays -// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. -constexpr std::size_t kPreserveVoiceCap = 8; - -// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a -// linear per-sample step derived from this at setupProcessing (gainRampStep_ = -// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing -// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as -// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is -// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ -// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. -constexpr double kGainRampSeconds = 0.020; - -// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy -// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the -// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic -// strength) is the requirement: two instances sharing a key is the copy-collision -// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision -// vanishingly unlikely. Off-thread only. -std::string mintUsageInstanceGuid() { - std::random_device rd; - std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); - std::uniform_int_distribution dist; - char buf[33] = {0}; - std::snprintf(buf, sizeof(buf), "%016llx%016llx", - static_cast(dist(gen)), - static_cast(dist(gen))); - return std::string(buf); -} - -// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). -// Off-thread only (blocking file I/O). Empty on any failure — the caller treats -// an unreadable WAV as "nothing to play". - -// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file -// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode -// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for -// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path -// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no -// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by -// the zoned build and the single-capture path so both decode identically for the active mode. -std::optional decodeRelative(const std::string& projectDir, - const std::string& relativePath, - ChannelMode mode) { - const std::string abs = resolveBankFile(projectDir, relativePath); - if (abs.empty()) return std::nullopt; - const std::vector bytes = readFileBytes(abs); - const WavLayout layout = parseWavLayout(bytes); - if (!layout.valid) return std::nullopt; - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, layout.frameCount()); - DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, - static_cast(layout.sampleRate)); - if (out.monoFrames.empty()) return std::nullopt; - return out; -} - -} // namespace - -FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { - // The host owns the returned reference. Cast up to the combined interface the SDK - // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. - return static_cast(new ReaSamplerProcessor()); -} - -// Out-of-line so unique_ptr sees the complete type here. -ReaSamplerProcessor::~ReaSamplerProcessor() = default; - -tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { - // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for - // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed - // shell. We own the shell (unique_ptr); the borrowed reference is valid because the - // processor outlives it. All other iids fall through to the SDK's queryInterface. - if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { - if (!embed_) embed_ = std::make_unique(this); - embed_->addRef(); - *obj = static_cast(embed_.get()); - return kResultOk; - } - return SingleComponentEffect::queryInterface(iid, obj); -} - -tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { - tresult result = SingleComponentEffect::initialize(context); - if (result != kResultOk) return result; - - // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the - // instrument still loads, it just has no live bank to play. - bridge_.connect(context); - - // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no - // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of - // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders - // dual-mono through the stereo bus (both channels equal, centered), which is audibly - // identical to a mono bus but never asks the host to re-map a live instance's pins. The - // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every - // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The - // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer - // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's - // re-routing of the live instance's pins across the arrangement change. A fixed arrangement - // is the maximally-standard VSTi shape and removes that whole negotiation surface. - addEventInput(STR16("MIDI In"), 16); - addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); - - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::terminate() { - // process() is not running at terminate: free the live + draining instruments and - // drain the graveyard. Take the pointers out of the atomics first so nothing else - // races them. - std::lock_guard lock(reloadMutex_); - delete live_.exchange(nullptr); - delete draining_.exchange(nullptr); - graveyard_.clear(); - return SingleComponentEffect::terminate(); -} - -tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { - // Activating: build the instrument from the currently-selected sample so the first - // block after activation can play. Deactivating: process is now GUARANTEED stopped by - // the host, so this is the safe point to reclaim the graveyard (the displaced engines - // no reload could free while active). The build/drain are off the audio thread — - // setActive is a main/UI-thread call. - if (state) { - // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED - // sample refs — it needs no bank read, so it plays regardless of whether the - // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). - // - // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a - // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic - // refreshRefsFromBank copies the refs in when the bank blob is readable by - // activation time, so an upgraded project plays on load without the instrument - // ever being opened (and the next save is self-contained). Residual load-order - // race, DAW-verifiable only: if the host activates this instance BEFORE the - // project's ext-state lines parse, the lift misses here and — with no editor open — - // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a - // pre-v10 instrument once after upgrading if it restores silent. - reloadInstrument(); - } else { - std::lock_guard lock(reloadMutex_); - // process is guaranteed stopped: free EVERYTHING. The live instrument too — its - // voices are frozen mid-flight, and if it survived deactivation the reactivate - // reload would displace it into the DRAIN slot, resurrecting stale sustained - // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), - // so nothing is lost by clearing here. - delete live_.exchange(nullptr); - delete draining_.exchange(nullptr); - graveyard_.clear(); - } - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { - sampleRate_ = setup.sampleRate; - maxBlockSize_ = setup.maxSamplesPerBlock; - // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at - // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host - // guarantees setupProcessing never overlaps process), read on the audio thread only. - if (sampleRate_ > 0.0) { - gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); - } - return SingleComponentEffect::setupProcessing(setup); -} - -tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { - if (!state) return kResultFalse; - // Read the whole component-state blob (the performance map, versioned). The blob is - // small; read in one shot into a growable buffer. - std::vector bytes; - std::uint8_t chunk[256]; - int32 got = 0; - while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { - bytes.insert(bytes.end(), chunk, chunk + got); - } - // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The - // selection and the zones are DISTINCT — the default face is one picked capture, zones - // are a demoted overlay — so both are restored explicitly (no more inferring a selection - // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only - // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so - // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 - // silent empty state (no first-sample fallback in reloadInstrument). - // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at - // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing - // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a - // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). - const ComponentState cs = deserializeComponentState(bytes, sampleRate_); - setSelectedSampleId(cs.selectionId); - // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a - // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the - // saved selection under first-match resolve. Reconciling here restores "the sample the - // editor shows is the sample the engine plays" for already-affected projects; authored - // Zone-view maps (any narrow key range) pass through untouched. - PerformanceMap restored = cs.map; - reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load - setPerformanceMap(restored); - // S8: restore the last-consumed assignment generation so a re-open does not re-apply a - // stale assign_request (the user may have manually changed the selection after the assign). - { - std::lock_guard lock(assignMarkerMutex_); - lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; - } - // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see - // initialize) — the mode only governs how the reload below decodes, so no bus work here. - { - std::lock_guard lock(channelModeMutex_); - channelMode_ = cs.channelMode; - channelModeExplicit_ = cs.channelModeExplicit; - } - // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 - // the editor's velocity knob is a concurrent UI-thread writer. - { - std::lock_guard lock(previewMutex_); - previewVelocity_ = cs.previewVelocity; - } - // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, - // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the - // reload below so the rebuilt engine is born with the saved polyphony/mode. - { - std::lock_guard lock(voiceParamsMutex_); - voiceCount_ = cs.voiceCount; - voiceMode_ = cs.voiceMode; - monoTrigger_ = cs.monoTrigger; - } - // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in - // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks - // it up at the next block start. - setMasterGainLinear(cs.masterGainLinear); - // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the - // reload so it decodes straight from them — no bank read required to play. A pre-v10 - // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob - // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), - // after which the next save is self-contained. - { - std::lock_guard lock(refsMutex_); - sampleRefs_ = cs.sampleRefs; - } - // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — - // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME - // for the copy-collision analysis (the fresh nonce means this incarnation can never - // be mistaken for the previous one's writes — or for a copy-sibling's). - { - std::lock_guard lock(usageMutex_); - instanceGuid_ = cs.instanceGuid; - usageNonce_.clear(); - } - // A new blob is new facts: a staleness proof latched against the PREVIOUS state does - // not carry over (#A — the legacy lift gets one fresh run per restored state). - legacyLiftConcluded_.store(false, std::memory_order_relaxed); - // Rebuild from the restored state (off-thread — setState is a load-time call). - reloadInstrument(); - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { - if (!state) return kResultFalse; - // Persist the full instance state (v3, S10): the single-capture selection id AND the - // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" - // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} - // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing - // sample #1. - ComponentState state_out; - state_out.selectionId = selectedSampleId(); - state_out.map = performanceMap(); - { - // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). - std::lock_guard lock(channelModeMutex_); - state_out.channelMode = channelMode_; - state_out.channelModeExplicit = channelModeExplicit_; - } - { - std::lock_guard lock(assignMarkerMutex_); - state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker - } - state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity - { - // Phase S: persist the voice-system parameters (component state v7). - std::lock_guard lock(voiceParamsMutex_); - state_out.voiceCount = voiceCount_; - state_out.voiceMode = voiceMode_; - state_out.monoTrigger = monoTrigger_; - } - state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) - // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to - // decode + play with no extension present. Filtered (on the snapshot copy, the member is - // untouched) to exactly what the instance currently plays, so the table cannot grow with - // browsing history. - state_out.sampleRefs = sampleRefs(); - retainRefs(state_out.sampleRefs, - referencedSampleIds(state_out.selectionId, state_out.map)); - // pS-usage: persist the publish identity (v11) so the instance's usage key is - // stable across sessions (records do not proliferate per reopen). - { - std::lock_guard lock(usageMutex_); - state_out.instanceGuid = instanceGuid_; - } - const std::vector bytes = serializeComponentState(state_out); - if (!bytes.empty()) { - const tresult wr = state->write(const_cast(bytes.data()), - static_cast(bytes.size()), nullptr); - if (wr != kResultOk) return wr; - } - return kResultOk; -} - -std::string ReaSamplerProcessor::selectedSampleId() { - std::lock_guard lock(selectionMutex_); - return selectedSampleId_; -} - -void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { - std::lock_guard lock(selectionMutex_); - selectedSampleId_ = id; -} - -PerformanceMap ReaSamplerProcessor::performanceMap() { - std::lock_guard lock(performanceMutex_); - return performanceMap_; -} - -void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { - std::lock_guard lock(performanceMutex_); - performanceMap_ = map; -} - -SampleRefs ReaSamplerProcessor::sampleRefs() { - std::lock_guard lock(refsMutex_); - return sampleRefs_; -} - -ChannelMode ReaSamplerProcessor::channelMode() { - std::lock_guard lock(channelModeMutex_); - return channelMode_; -} - -std::uint8_t ReaSamplerProcessor::previewVelocity() { - std::lock_guard lock(previewMutex_); - return previewVelocity_; -} - -void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { - // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview - // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. - if (velocity < 1) velocity = 1; - if (velocity > 127) velocity = 127; - std::lock_guard lock(previewMutex_); - previewVelocity_ = velocity; -} - -int ReaSamplerProcessor::voiceCount() { - std::lock_guard lock(voiceParamsMutex_); - return voiceCount_; -} - -void ReaSamplerProcessor::setVoiceCount(int count) { - // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's - // control can never disagree about the legal polyphony span. - if (count < kMinVoiceCount) count = kMinVoiceCount; - if (count > kMaxVoiceCount) count = kMaxVoiceCount; - { - std::lock_guard lock(voiceParamsMutex_); - if (voiceCount_ == count) return; // no-op: don't churn a rebuild - voiceCount_ = count; - } - // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from - // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change - // touches no audio data) and the displaced instrument keeps rendering its ringing tails, - // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every - // zone from disk. Same contract for the mode/trigger setters below. - rebuildVoiceEngine(); -} - -VoiceMode ReaSamplerProcessor::voiceMode() { - std::lock_guard lock(voiceParamsMutex_); - return voiceMode_; -} - -void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { - { - std::lock_guard lock(voiceParamsMutex_); - if (voiceMode_ == mode) return; - voiceMode_ = mode; - } - rebuildVoiceEngine(); -} - -MonoTrigger ReaSamplerProcessor::monoTrigger() { - std::lock_guard lock(voiceParamsMutex_); - return monoTrigger_; -} - -void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { - { - std::lock_guard lock(voiceParamsMutex_); - if (monoTrigger_ == trigger) return; - monoTrigger_ = trigger; - } - rebuildVoiceEngine(); -} - -void ReaSamplerProcessor::setMasterGainLinear(double linear) { - // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = - // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; - // no rebuild, no lock (a post-sum output trim is not a keymap fact). - if (!(linear >= 0.0)) linear = 0.0; // also catches NaN - const double maxLin = masterGainMaxLinear(); - if (linear > maxLin) linear = maxLin; - masterGain_.store(static_cast(linear), std::memory_order_relaxed); -} - -void ReaSamplerProcessor::previewNoteOn(int note) { - if (note < 0) note = 0; - if (note > 127) note = 127; - const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request - // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as - // long as we never land back on the exact value the audio thread last consumed in one step — - // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). - const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; - const std::uint32_t packed = (static_cast(seq) << 16) | - (static_cast(vel) << 8) | - static_cast(note & 0xFF); - previewOnRequest_.store(packed, std::memory_order_release); -} - -void ReaSamplerProcessor::previewNoteOff(int note) { - if (note < 0) note = 0; - if (note > 127) note = 127; - const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; - const std::uint32_t packed = (static_cast(seq) << 16) | - static_cast(note & 0xFF); - previewOffRequest_.store(packed, std::memory_order_release); -} - -void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { - { - std::lock_guard lock(channelModeMutex_); - // The editor toggle is a DELIBERATE choice either way: latch explicit even on a - // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). - channelModeExplicit_ = true; - if (channelMode_ == mode) return; // no decode change: don't churn a reload - channelMode_ = mode; - } - // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no - // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread - // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. - reloadInstrument(); -} - -tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( - SpeakerArrangement* inputs, int32 numIns, - SpeakerArrangement* outputs, int32 numOuts) { - // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a - // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. - // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) - // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a - // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. - if (numIns < 0 || numOuts < 0) return kInvalidArgument; - if (numIns > 0) return kResultFalse; // no audio input bus to arrange - if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; - return kResultFalse; -} - -std::string ReaSamplerProcessor::reloadInstrument() { - // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so - // the retired-slot free is single-writer. This mutex is NEVER taken on the audio - // thread — process() only touches the atomic. - std::lock_guard lock(reloadMutex_); - - // Mint this reload's generation number first so we can stamp the built instrument - // with it before publishing. Under reloadMutex_ no other reload races here. - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - - // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of - // truth for what to decode. The live bank blob, WHEN readable, is folded into the - // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in - // mechanism and the S9 recapture sync in one — but its absence changes NOTHING - // below: a project restored before the extension's PROJEXTSTATE parses (or with - // the extension absent entirely) resolves + plays from the persisted refs. The - // project dir comes from REAPER itself (EnumProjects), not from the extension. - const std::string selId = selectedSampleId(); - const PerformanceMap map = performanceMap(); - const std::vector ids = referencedSampleIds(selId, map); - SampleRefs refs; - { - std::optional banksJson = - bridge_.readReasamplerExtState(kProjExtBanksKey); - std::lock_guard rl(refsMutex_); - if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); - // The LOAD path never prunes the owned table: dropping entries here on a transient - // bank miss could destroy the owned intrinsics of the previous selection — the ONE - // copy that survives with the extension absent. Entries for de-referenced ids stay - // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, - // where getState filters its snapshot via retainRefs to what the instance plays. - refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) - } - const std::string projectDir = bridge_.activeProjectDir(); - // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). - // Read once under its mutex, off the audio thread, before the decode loop. The single- - // capture branch below may auto-default it (GA) before its decode. - ChannelMode mode = channelMode(); - // Phase S: snapshot the voice-system parameters once — they are baked into the built - // engine's construction (the engine's config is immutable; a later change rebuilds). - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - std::string resolvedId; - std::unique_ptr built; - Keymap km; - bool haveKeymap = false; - - // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its - // zones against the OWNED refs (an id with no ref drops cleanly), decode each - // zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays - // its sample repitched from its effective root note (override > ref intrinsic > - // C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped - // (not the whole map): the defined no-play, no crash, no retry loop. - if (!map.empty()) { - const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map); - if (!resolved.zones.empty()) { - std::vector decoded; - std::vector kept; - decoded.reserve(resolved.zones.size()); - kept.reserve(resolved.zones.size()); - for (const ResolvedZone& rz : resolved.zones) { - std::optional pcm = - decodeRelative(projectDir, rz.relativePath, mode); - if (!pcm) continue; // unreadable/missing WAV -> drop this zone - kept.push_back(rz); - decoded.push_back(std::move(*pcm)); - } - km = buildZonedKeymap(kept, decoded); - haveKeymap = !km.zones.empty(); - } - } - - // 3. Single-capture fast path (S10): an empty performance map plays the ONE - // deliberately-selected capture chromatically across the whole keyboard, resolved - // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a - // selection with no ref) resolves to nothing, so an un-picked instrument stays - // SILENT (the editor shows its "pick a capture" empty state) rather than - // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). - if (!haveKeymap) { - if (const SelectedSample* sel = findRef(refs, selId)) { - // GA auto-default: channelModeFor computes the mode from the loaded capture's - // REQUESTED channel count (always 2 for extension captures; mono only for - // ingest-imported mono files). An unknown count (0) or explicit user choice - // returns the current mode unchanged. Decode-only: the output bus is fixed - // stereo, so no bus work follows a flip. - { - std::lock_guard cm(channelModeMutex_); - channelMode_ = channelModeFor(sel->channelCount, channelMode_, - channelModeExplicit_); - mode = channelMode_; - } - std::optional pcm = - decodeRelative(projectDir, sel->relativePath, mode); - if (pcm) { - km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, - sel->rootNote, sel->loop, - std::move(pcm->framesR)); - haveKeymap = true; - resolvedId = selId; // the concrete pick that resolved - } - } - } - - if (haveKeymap) { - // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). - // Every voice's shifter is pre-sized to this off-thread here, so process()-time - // note-on never allocates. Floored at 2 so a valid window is always a real ring - // (which also covers a pathological host rate <= 0 — no rate literal needed). - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - built = std::make_unique( - std::move(km), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - } - - // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the - // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — - // a reload never cuts a sounding note; the next note-on plays the new state. The - // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard - // (process may still be mid-block reading it). A null `built` (no ref / unreadable - // WAV) installs silence while the displaced tails still ring out via the drain. - // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted - // pointer is re-owned by the graveyard. - publishBuiltLocked(std::move(built)); - - // 5. pS-usage: publish this instance's held captures so the extension's prune can - // never reclaim them (see publishUsage). AFTER the instrument swap, still off the - // audio thread and under reloadMutex_. Publishes regardless of decode success: - // the holds are the refs the instance RETAINS (its play-set), not what decoded — - // a transiently unreadable WAV must stay protected. - publishUsage(refs, ids); - return resolvedId; -} - -void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, - const std::vector& ids) { - if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do - - UsageRecord mine; - mine.trackGuid = bridge_.currentTrackGuid(); - for (const std::string& id : ids) { - if (const SelectedSample* ref = findRef(refs, id)) { - if (!ref->relativePath.empty()) { - mine.holds.push_back(UsageHold{id, ref->relativePath}); - } - } - } - - std::lock_guard lock(usageMutex_); - // A never-published instance with nothing held writes nothing — no key litter for - // fresh/empty instances. Once an identity exists, empties DO publish (they release - // holds the prune would otherwise keep protecting). - if (instanceGuid_.empty() && mine.holds.empty()) return; - if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); - // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so - // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track - // sibling's byte-identical hold set can never pass as ours (its nonce differs), so - // siblings always union and never clean-replace over each other's held paths. - if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); - mine.ownerNonce = usageNonce_; - - const std::optional existing = - bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); - const UsagePublishPlan plan = planUsagePublish(existing, mine); - if (plan.remint) { - // This state was cloned onto another track (FX copy / track duplication): take a - // fresh identity and leave the original's record untouched. The abandoned old - // identity's record dies by the extension's liveness rule when its track no - // longer hosts an instance. getState persists the new guid on the next save. - instanceGuid_ = mintUsageInstanceGuid(); - } else if (plan.skipWrite) { - return; // idle tick, or a union that adds nothing — no ext-state churn - } - bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); -} - -void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { - // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by - // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. - // - // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is - // the minimum installedAt process() published over the pointers it holds. Both - // slots are monotone in installedAt, so seen is monotone and any future process() - // load yields installedAt >= seen — an entry below seen is provably unreachable - // (see the header proof). Remaining entries drain at setActive(false) / terminate() - // when process is guaranteed stopped. - const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); - graveyard_.erase( - std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const std::unique_ptr& e) { - return e->installedAt < seen; - }), - graveyard_.end()); - LoadedInstrument* prev = live_.exchange(built.release()); - LoadedInstrument* evicted = draining_.exchange(prev); - if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); -} - -void ReaSamplerProcessor::rebuildVoiceEngine() { - // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: - // a voice-param change touches NO audio data, so this rebuilds the engine - // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — - // and publishes through the same drain-slot swap, so ringing tails survive. - std::lock_guard lock(reloadMutex_); - LoadedInstrument* cur = live_.load(std::memory_order_acquire); - if (!cur) return; // nothing loaded: the new params bake into the next real reload. - - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - - // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap - // is immutable after construction, and under reloadMutex_ nobody can free `cur`. - Keymap km = cur->keymap; - auto built = std::make_unique( - std::move(km), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - publishBuiltLocked(std::move(built)); -} - -void ReaSamplerProcessor::retireIdleDrain() { - // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or - // it still sounds" — the common case costs one relaxed load and no mutex. - const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); - if (idleGen == 0) return; - std::lock_guard lock(reloadMutex_); - LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - // Retire ONLY if the publication names the drain currently in the slot. A stale value - // (about an already-evicted, older drain) can never match the newer occupant's - // installedAt — the slot is monotone in generation — so a mid-swap race is closed by - // this identity check, not by timing. - if (!drain || drain->installedAt != idleGen) return; - draining_.store(nullptr, std::memory_order_release); - graveyard_.push_back(std::unique_ptr(drain)); - // Prune what is now provably unreachable — the same monotone-generation proof as the - // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be - // held by process() now or ever again. The just-parked drain frees here immediately when - // process() has already published past it; otherwise on the next reload/retire/deactivate. - const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); - graveyard_.erase( - std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const std::unique_ptr& e) { - return e->installedAt < seen; - }), - graveyard_.end()); -} - -bool ReaSamplerProcessor::legacyLiftShouldRun() { - // #A terminating guard for the pre-v10 legacy lift. The caller has already established - // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before - // paying for a full reload. Once concluded, the steady state is this one relaxed load — - // no bank read, no parse, no reload churn. - if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; - const LegacyLiftDecision decision = legacyLiftDecision( - bridge_.readReasamplerExtState(kProjExtBanksKey), - referencedSampleIds(selectedSampleId(), performanceMap())); - if (decision == LegacyLiftDecision::Stale) { - // Provably stale (the bank parses and knows none of the referenced ids): give up - // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, - // and the genChanged reload refreshes the refs without consulting this latch. - legacyLiftConcluded_.store(true, std::memory_order_relaxed); - return false; - } - return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) -} - -ReaSamplerProcessor::BankSyncResult -ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { - // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call - // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER - // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. - BankSyncResult result; - - // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer - // cadence that drives reloads — an edited-away instrument stops costing memory as soon - // as its tails die instead of squatting in the drain slot until the next reload. - retireIdleDrain(); - - // --- S8: assignment-request consume FIRST ------------------------------------- - // Decode the pending assignment request (nullopt when absent/malformed). Resolve its - // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when - // the sampleId names an existing sample (the reader requirement — an unresolvable pair is - // dropped). Then run the pure consume decision against this instance's persisted marker. - std::optional request; - if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { - request = decodeAssignmentRequest(*raw); - } - - bool resolves = false; - if (request) { - // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request - // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). - if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { - resolves = selectSample(*banksJson, request->sampleId).has_value(); - } - } - - // Read lastConsumed and conditionally write it back under a single lock scope so there - // is no interleave window between the read and the write (a concurrent getState could - // otherwise observe a stale marker between the two separate lock acquisitions). - std::int64_t lastConsumed = 0; - const AssignConsumeDecision decision = [&] { - std::lock_guard lock(assignMarkerMutex_); - lastConsumed = lastConsumedAssignGeneration_; - const AssignConsumeDecision d = - consumeDecision(request, lastConsumed, resolves, isFocusedTarget); - // Advance the persisted consumed marker whenever the decision consumed the request - // (applied OR dropped-as-seen). getState will persist it on the next project save so - // a re-open does not re-apply. A non-target instance leaves the marker (decision - // returns it unchanged) so it stays eligible if focus later lands here. - if (d.consumedGeneration != lastConsumed) { - lastConsumedAssignGeneration_ = d.consumedGeneration; - } - return d; - }(); - - if (decision.apply) { - // Apply the assignment as this instance's own selection (the same path a user card-pick - // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below - // rebuilds against the new selection, so skip a redundant reload here. - setSelectedSampleId(decision.sampleId); - // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone - // materialized for the previously loaded sample would shadow the assigned pick under - // first-match resolve. Authored maps (any narrow key range) are untouched. - PerformanceMap reconciled = performanceMap(); - if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { - setPerformanceMap(reconciled); - } - result.applied = true; - } - - // --- S9: bank-generation change-detection ------------------------------------- - // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll - // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — - // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload - // on open would only churn. A later - // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the - // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). - std::int64_t currentGen = kBankGenerationAbsent; - if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { - currentGen = parseBankGeneration(*rawGen); - } - const bool firstPoll = (lastSeenBankGeneration_ < 0); - const bool genChanged = - !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); - lastSeenBankGeneration_ = currentGen; - - // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) - // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had - // nothing to decode unless the bank happened to be readable already. Reload on this - // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs - // when readable, after which the table is non-empty and this never fires again (the - // next save is then self-contained). A deliberately-empty instance has no intent and - // never churns; a bank that is not readable YET retries a cheap null publish on the - // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob - // PARSES and no referenced id resolves in it, the ids are provably stale — there is - // nothing to lift, so the lift concludes permanently instead of churning a full bank - // read + reload every tick forever. This is a MIGRATION convenience for old projects, - // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). - bool legacyLift = false; - if (!genChanged && !result.applied && sampleRefs().empty()) { - const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); - legacyLift = hasIntent && legacyLiftShouldRun(); - } - - if (genChanged || result.applied || legacyLift) { - reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) - // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank - // view. A legacy lift counts only when it actually landed an instrument (otherwise - // every retry tick would churn the editor's caches for nothing). - result.reloaded = - genChanged || - (legacyLift && live_.load(std::memory_order_acquire) != nullptr); - } - return result; -} - -tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments - // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt - // over the pointers held so the off-thread graveyard pruner knows exactly which - // generations this block is holding (see the header proof). - // - // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an - // ordering race: reading reloadGeneration_ after the slots could observe a generation - // newer than the pointers we actually hold, causing the pruner to free an instrument - // process is still reading. installedAt was set on the reload path before the atomic - // exchange that made the instrument visible. - // - // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the - // last reload: its already-sounding voices keep rendering (and receive note-offs) so a - // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. - // A racing reload can briefly leave the same pointer in both slots (live_ was loaded - // before the swap, draining_ after); collapse that to live-only so one engine is never - // advanced twice per frame. - LoadedInstrument* inst = live_.load(std::memory_order_acquire); - LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - if (drain == inst) drain = nullptr; - std::uint64_t heldGen = 0; - if (inst && drain) { - heldGen = inst->installedAt < drain->installedAt ? inst->installedAt - : drain->installedAt; - } else if (inst) { - heldGen = inst->installedAt; - } else if (drain) { - heldGen = drain->installedAt; - } - processGeneration_.store(heldGen, std::memory_order_release); - - // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine - // voice silent) by naming its OWN installedAt (0 = no drain / still - // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no - // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply - // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. - drainIdleGeneration_.store( - (drain && drain->fullyIdle()) ? drain->installedAt : 0, - std::memory_order_relaxed); - - // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps - // events at block granularity (no per-event sample-offset split) — audible timing is - // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. - // Note-offs also route to the DRAIN engine so a note held across a reload releases - // its old-snapshot voice too (otherwise it would sustain until the next reload). - if (data.inputEvents) { - const int32 count = data.inputEvents->getEventCount(); - for (int32 i = 0; i < count; ++i) { - Event e; - if (data.inputEvents->getEvent(i, e) != kResultOk) continue; - if (e.type == Event::kNoteOnEvent) { - // A note-on with velocity 0 is a note-off by MIDI convention. - const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); - if (vel <= 0) { - if (inst) inst->engine.noteOff(e.noteOn.pitch); - if (drain) drain->engine.noteOff(e.noteOn.pitch); - } else if (inst) { - inst->engine.noteOn(e.noteOn.pitch, vel); - } - } else if (e.type == Event::kNoteOffEvent) { - if (inst) inst->engine.noteOff(e.noteOff.pitch); - if (drain) drain->engine.noteOff(e.noteOff.pitch); - } else if (e.type == Event::kLegacyMIDICCOutEvent) { - // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a - // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism - // — the type is nominally an output event; DAW-verify, see handoff). - // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR - // release tail; Trigger one-shots play through their bounded play length. - // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless - // of play mode, including Trigger one-shots that ignore CC 123. This is the - // true "panic" for a ringing one-shot (e.g. a full-length capture). - // Both clear the mono held stack. Both apply to live AND drain. A ringing - // preview note is a real engine voice since the PreviewCard retirement, so - // the panics cover it with no separate routing. allNotesOff / allSoundsOff - // are RT-safe (no allocation, bounded scans). - const auto cc = static_cast(e.midiCCOut.controlNumber); - if (cc == kCtrlAllSoundsOff) { - if (inst) inst->engine.allSoundsOff(); - if (drain) drain->engine.allSoundsOff(); - } else if (cc == kCtrlAllNotesOff) { - if (inst) inst->engine.allNotesOff(); - if (drain) drain->engine.allNotesOff(); - } - } - } - } - - // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed - // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last - // one we consumed; fire it once, then latch the sequence so the same request never re-fires. - // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact - // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real - // voice: it counts against the voice count, can steal / be stolen, and respects - // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's - // isolation). The editor posts the root note, so it plays at unity. - // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument - // is loaded does not re-fire stale on the next instrument load. - { - const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); - const std::uint16_t onSeq = static_cast(on >> 16); - if (onSeq != 0 && onSeq != previewOnConsumed_) { - previewOnConsumed_ = onSeq; - if (inst) { - const int vel = static_cast((on >> 8) & 0xFF); - const int note = static_cast(on & 0xFF); - if (vel > 0) inst->engine.noteOn(note, vel); - } - } - } - { - const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); - const std::uint16_t offSeq = static_cast(off >> 16); - if (offSeq != 0 && offSeq != previewOffConsumed_) { - // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending - // while nothing was loaded would otherwise survive until a (heal) reload lands - // and release the NEXT preview press in the same block. - previewOffConsumed_ = offSeq; - // Route the preview note-off to BOTH engines (mirror of the host note-off): a - // preview held across a reload — e.g. a curve edit committed mid-press — must - // release the old-snapshot voice now draining, not just the (fresh) live one. - // NOTE: preview shares the host-MIDI note space — noteOff releases the newest - // voice at that pitch, so a preview release can release a host-held note at - // the same pitch (inherent to routing preview through the real note path). - if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); - if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); - } - } - - if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { - embedPeak_.store(0.f, std::memory_order_relaxed); - return kResultOk; - } - AudioBusBuffers& out = data.outputs[0]; - const int32 frames = data.numSamples; - - // 64-bit host processing is not supported by the mono float core; emit silence - // rather than mis-render. REAPER runs 32-bit float by default. - if (data.symbolicSampleSize != kSample32) { - embedPeak_.store(0.f, std::memory_order_relaxed); - for (int32 ch = 0; ch < out.numChannels; ++ch) { - if (double* buf = out.channelBuffers64[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; - } - } - out.silenceFlags = (out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1); - return kResultOk; - } - - // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked - // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here - // we simply match the buffers the host handed us: >=2 channels -> true stereo render into - // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the - // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. - float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; - float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; - if (ch0 && ch1) { - // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo - // path (both channels equal), so a mono capture in stereo mode is centered, not silent. - // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). - for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } - if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); - if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); - // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so - // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. - // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the - // actual output. Branch-free inner loop; early-out when already at target. RT-safe. - { - const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step - const float gSnap = 0.5f * gStep; - const float diff = gTarget - gainCurrent_; - if (diff < -gSnap || diff > gSnap) { - // Ramp toward target: step per sample, then apply the per-sample gain. - for (int32 i = 0; i < frames; ++i) { - const float d = gTarget - gainCurrent_; - if (d > gStep) gainCurrent_ += gStep; - else if (d < -gStep) gainCurrent_ -= gStep; - else gainCurrent_ = gTarget; - ch0[i] *= gainCurrent_; - ch1[i] *= gainCurrent_; - } - } else { - gainCurrent_ = gTarget; - if (gTarget != 1.f) { - for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } - } - } - } - // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). - for (int32 ch = 2; ch < out.numChannels; ++ch) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; - } - } - // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. - float peak = 0.f; - for (int32 i = 0; i < frames; ++i) { - const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; - const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; - if (a0 > peak) peak = a0; - if (a1 > peak) peak = a1; - } - embedPeak_.store(peak, std::memory_order_relaxed); - } else if (ch0) { - // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; - // the replicate is defensive for a host that still hands >1 channel on a mono bus). - for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; - if (inst) inst->engine.render(ch0, static_cast(frames)); - if (drain) drain->engine.render(ch0, static_cast(frames)); - // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: - // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. - { - const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step - const float gSnap = 0.5f * gStep; - const float diff = gTarget - gainCurrent_; - if (diff < -gSnap || diff > gSnap) { - for (int32 i = 0; i < frames; ++i) { - const float d = gTarget - gainCurrent_; - if (d > gStep) gainCurrent_ += gStep; - else if (d < -gStep) gainCurrent_ -= gStep; - else gainCurrent_ = gTarget; - ch0[i] *= gainCurrent_; - } - } else { - gainCurrent_ = gTarget; - if (gTarget != 1.f) { - for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; - } - } - } - float peak = 0.f; - for (int32 i = 0; i < frames; ++i) { - const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; - if (a > peak) peak = a; - } - embedPeak_.store(peak, std::memory_order_relaxed); - for (int32 ch = 1; ch < out.numChannels; ++ch) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; - } - } - } - - // Report silence only when nothing is loaded (lets the host optimize when idle). - // With an instrument loaded — or a drain snapshot still ringing out — we clear the - // flag so a ringing voice is not skipped. - out.silenceFlags = (inst || drain) ? 0 - : ((out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1)); - return kResultOk; -} - -IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { - if (name && FIDStringsEqual(name, ViewType::kEditor)) { - return new ReaSamplerEditor(this); - } - return nullptr; -} - -} // namespace reasampler::vst diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp index a1114cf..fdb1441 100644 --- a/tests/test_browser_scroll.cpp +++ b/tests/test_browser_scroll.cpp @@ -160,6 +160,26 @@ static void testFilterIndices() { CHECK(filterNameIndices(names, "zzz").empty()); } +// --- The Browse-modal regions (Q-W2v hoist, T2-06) --------------------------- + +// Title / search / content / footer partition the window top-to-bottom without gaps; +// Back right-anchors in the title band; Cancel/Load flank the footer. +static void testBrowseModalPartition() { + const BrowseModal m = computeBrowseModal(840, 620); + CHECK(m.title.y == 0 && m.title.width == 840 && m.title.height == kTitleHeight); + CHECK(m.back.right() == 840 - kPad && m.back.bottom() <= m.title.bottom()); + CHECK(m.search.y == m.title.bottom()); + CHECK(m.search.height == searchBoxRect(840).height); + CHECK(m.content.y == m.search.bottom()); + CHECK(m.content.bottom() == 620 - 30); // the footer band (kBrowseFooterH) + CHECK(m.cancel.x == kPad); + CHECK(m.confirm.right() == 840 - kPad); + CHECK(m.cancel.y == m.content.bottom() + 3 && m.cancel.y == m.confirm.y); + // Degenerate short window: the footer clamps below the search box (no inversion). + const BrowseModal s = computeBrowseModal(840, 40); + CHECK(s.content.bottom() >= s.content.y); +} + int main() { testContentHeight(); testMaxOffsetFitsAndOverflows(); @@ -174,6 +194,7 @@ int main() { testSearchBoxRect(); testNameMatch(); testFilterIndices(); + testBrowseModalPartition(); if (g_fail == 0) std::printf("browser_scroll: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp new file mode 100644 index 0000000..d84ad8c --- /dev/null +++ b/tests/test_component_state_io.cpp @@ -0,0 +1,179 @@ +// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite — the full +// envelope/payload version ladder, every legacy lift, the golden byte fixtures — +// lives in test_sample_map.cpp and runs unmodified against the split module; this +// target exists as the module's OWN executable (house rule: every pure module has +// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine +// (T2-07): it links component_state_io + velocity_curve + master_gain only — a +// sampler_core/pitch_shift symbol reaching this link is a regression. + +#include "../src/core/instrument/map/component_state_io.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::map; + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; \ + } \ + } while (0) + +// A full round-trip through the CURRENT envelope (v11): every field survives. +static void testComponentStateRoundTrip() { + ComponentState in; + in.selectionId = "smp-1"; + in.channelMode = ChannelMode::Stereo; + in.channelModeExplicit = true; + in.lastConsumedAssignGeneration = 42; + in.previewVelocity = 99; + in.voiceCount = 7; + in.voiceMode = VoiceMode::Mono; + in.monoTrigger = MonoTrigger::Legato; + in.masterGainLinear = 0.5; + in.instanceGuid = "0123456789abcdef0123456789abcdef"; + SampleRefEntry e; + e.sampleId = "smp-1"; + e.ref.relativePath = "bank/smp-1.wav"; + e.ref.rootNote = 64; + e.ref.loop.hasLoop = true; + e.ref.loop.start = 100; + e.ref.loop.end = 2000; + e.ref.channelCount = 2; + e.displayName = "My Capture"; + in.sampleRefs.push_back(e); + PerformanceZone z; + z.sampleId = "smp-1"; + z.lowNote = 30; + z.highNote = 90; + z.rootOverride = 61; + z.startPoint = 5; + z.keyTrack = 1.5; + z.play.playMode = PlayMode::Trigger; + z.play.trigger.lengthFraction = 0.75; + z.play.trigger.fadeInFrames = 441; + z.play.trigger.fadeOutFrames = 882; + in.map.zones.push_back(z); + + const std::vector bytes = serializeComponentState(in); + const ComponentState out = deserializeComponentState(bytes, 48000.0); + + CHECK(out.selectionId == "smp-1"); + CHECK(out.channelMode == ChannelMode::Stereo); + CHECK(out.channelModeExplicit); + CHECK(out.lastConsumedAssignGeneration == 42); + CHECK(out.previewVelocity == 99); + CHECK(out.voiceCount == 7); + CHECK(out.voiceMode == VoiceMode::Mono); + CHECK(out.monoTrigger == MonoTrigger::Legato); + CHECK(out.masterGainLinear == 0.5); + CHECK(out.instanceGuid == "0123456789abcdef0123456789abcdef"); + CHECK(out.sampleRefs.size() == 1); + if (out.sampleRefs.size() == 1) { + CHECK(out.sampleRefs[0].sampleId == "smp-1"); + CHECK(out.sampleRefs[0].ref.relativePath == "bank/smp-1.wav"); + CHECK(out.sampleRefs[0].ref.rootNote == 64); + CHECK(out.sampleRefs[0].ref.loop.hasLoop); + CHECK(out.sampleRefs[0].ref.loop.start == 100); + CHECK(out.sampleRefs[0].ref.loop.end == 2000); + CHECK(out.sampleRefs[0].ref.channelCount == 2); + CHECK(out.sampleRefs[0].displayName == "My Capture"); + } + CHECK(out.map.zones.size() == 1); + if (out.map.zones.size() == 1) { + const PerformanceZone& oz = out.map.zones[0]; + CHECK(oz.sampleId == "smp-1"); + CHECK(oz.lowNote == 30); + CHECK(oz.highNote == 90); + CHECK(oz.rootOverride && *oz.rootOverride == 61); + CHECK(oz.startPoint && *oz.startPoint == 5); + CHECK(oz.keyTrack == 1.5); + CHECK(oz.play.playMode == PlayMode::Trigger); + CHECK(oz.play.trigger.lengthFraction == 0.75); + CHECK(oz.play.trigger.fadeInFrames == 441); + CHECK(oz.play.trigger.fadeOutFrames == 882); + } +} + +// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in +// either is a byte-format break the round-trip alone can't prove (both sides could +// drift together). Pins the writer's absolute bytes. +static void testEnvelopePrefixBytesFrozen() { + ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones + const std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() > 5); + if (bytes.size() > 5) { + CHECK(bytes[0] == 11 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0); + CHECK(bytes[4] == 0); // ChannelMode::Mono + } + CHECK(kComponentStateVersion == 11); + CHECK(kZonesPayloadVersion == 7); + CHECK(kZonesFormatMarker == 0xFFFFFF00u); +} + +// A v1 selection blob lifts to {id, one full-keyboard zone} — the oldest live lift. +static void testV1SelectionLift() { + const std::vector v1 = serializeSelection("old-pick"); + const ComponentState out = deserializeComponentState(v1, 48000.0); + CHECK(out.selectionId == "old-pick"); + CHECK(out.map.zones.size() == 1); + if (out.map.zones.size() == 1) { + CHECK(out.map.zones[0].sampleId == "old-pick"); + CHECK(out.map.zones[0].lowNote == 0); + CHECK(out.map.zones[0].highNote == 127); + } +} + +// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. +static void testTruncationDegradesCleanly() { + ComponentState in; + in.selectionId = "smp-2"; + PerformanceZone z; + z.sampleId = "smp-2"; + in.map.zones.push_back(z); + const std::vector bytes = serializeComponentState(in); + for (std::size_t cut = 0; cut < bytes.size(); ++cut) { + const std::vector part(bytes.begin(), + bytes.begin() + static_cast(cut)); + const ComponentState out = deserializeComponentState(part, 48000.0); + (void)out; // reaching here without UB/throw is the contract under test + } + CHECK(true); +} + +// serializePerformance/deserializePerformance round-trip through the v2 envelope. +static void testPerformanceRoundTrip() { + PerformanceMap in; + PerformanceZone z; + z.sampleId = "zone-a"; + z.lowNote = 10; + z.highNote = 20; + in.zones.push_back(z); + const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0); + CHECK(out.zones.size() == 1); + if (out.zones.size() == 1) { + CHECK(out.zones[0].sampleId == "zone-a"); + CHECK(out.zones[0].lowNote == 10); + CHECK(out.zones[0].highNote == 20); + } +} + +int main() { + testComponentStateRoundTrip(); + testEnvelopePrefixBytesFrozen(); + testV1SelectionLift(); + testTruncationDegradesCleanly(); + testPerformanceRoundTrip(); + if (failures == 0) { + std::printf("component_state_io_tests: all tests passed\n"); + return 0; + } + std::printf("component_state_io_tests: %d FAILURE(S)\n", failures); + return 1; +} diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index 858fcb7..8a4eef2 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -311,6 +311,78 @@ static void testZoneHitTestMisses() { CHECK(zoneHitTest(L, 3, L.sampleList.x + 2, midY).zoneIndex == -1); } +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- + +// The band stack at the default 840x620 with a 120px deck: title / hero / cluster / +// deck in order, hero elastic (absorbs the slack), deck bottom-anchored at kPad. +static void testSampleBandsStackAndElasticHero() { + const SampleBands b = computeSampleBands(840, 620, 120); + CHECK(b.title.y == 0 && b.title.height == kTitleHeight && b.title.width == 840); + CHECK(b.hero.y == b.title.bottom()); + CHECK(b.hero.height >= 150); // above the hero floor + CHECK(b.cluster.y > b.hero.bottom()); // cluster below the hero (+gap) + CHECK(b.deck.bottom() == 620 - kPad); // deck bottom-anchored + CHECK(b.deck.height == 120); + // Nav buttons right-anchored inside the title band, Browse left of Zone. + CHECK(b.navZone.right() == 840 - kPad); + CHECK(b.navBrowse.right() < b.navZone.x); + CHECK(b.navZone.bottom() <= b.title.bottom()); + // A too-short window: the hero keeps its floor; the lower bands clip below. + const SampleBands s = computeSampleBands(840, 200, 120); + CHECK(s.hero.height == 150); + CHECK(s.deck.bottom() > 200); // clips past the window bottom (defensive case) +} + +// The cluster's right-anchored run tiles left of the channel toggle without overlap: +// rootStrip | preview | velCell(velKnob+velLabel) | curveBtn | (toggle). +static void testClusterRectsRunAndKnobCentering() { + const Rect cluster = Rect::ltrb(0, 500, 840, 552); + const ChannelToggleRects chan = channelToggleRects(cluster); + CHECK(chan.stereo.right() == 840 - kPad); + CHECK(chan.mono.right() == chan.stereo.x); + const ClusterRects cr = clusterRects(cluster, chan.mono, 28); + CHECK(cr.curveBtn.right() == chan.mono.x - kPad); + CHECK(cr.velCell.right() == cr.curveBtn.x - kPad); + CHECK(cr.preview.right() == cr.velCell.x - kPad); + CHECK(cr.rootStrip.x == cluster.x + kPad); + CHECK(cr.rootStrip.right() == cr.preview.x - kPad); + // The knob square centers in the cell and the label band sits beneath it. + CHECK(cr.velKnob.width == 28); + CHECK(cr.velKnob.x - cr.velCell.x == cr.velCell.right() - cr.velKnob.right()); + CHECK(cr.velLabel.y == cr.velKnob.bottom()); + CHECK(cr.velLabel.bottom() == cr.velCell.bottom()); +} + +// The Zone surface: content below the title; strip below the add/delete row; the note +// entry fields tile in three ordered segments; deck + curve button split the panel. +static void testZoneSurfaceLayoutAnchors() { + const Rect content = zoneContentArea(840, 620); + CHECK(content.y == kTitleHeight && content.bottom() == 620); + const Rect back = zoneBackRect(840, 620); + CHECK(back.right() == 840 - kPad && back.bottom() <= kTitleHeight); + const Rect addR = zoneAddRect(content); + const Rect delR = zoneDeleteRect(addR); + CHECK(addR.y == content.y + 4); + CHECK(delR.x == addR.right() + 8 && delR.y == addR.y); + const Rect strip = zonesStripArea(content); + CHECK(strip.y == addR.bottom() + 12); + CHECK(strip.x == content.x + kPad && strip.right() == content.right() - kPad); + const Rect fields = noteEntryFieldsArea(content); + CHECK(fields.y == strip.bottom() + 8); + const Rect f0 = noteEntryFieldRect(fields, 0); + const Rect f1 = noteEntryFieldRect(fields, 1); + const Rect f2 = noteEntryFieldRect(fields, 2); + CHECK(f0.x < f1.x && f1.x < f2.x); + CHECK(f2.right() == fields.right()); + CHECK(noteEntryFieldRect(fields, 3).width == 0); // out-of-range -> empty + const Rect panel = zonesControlPanel(content); + const Rect deck = zonesDeckArea(content); + const Rect curve = zonesCurveButton(content); + CHECK(panel.y == strip.bottom() + 8 + 18 + 8); + CHECK(deck.y == panel.y && deck.right() < curve.x); // curve column reserved + CHECK(curve.right() == panel.right() && curve.y == panel.y); +} + int main() { testContainsHalfOpen(); testContainsDegenerate(); @@ -332,6 +404,9 @@ int main() { testZoneRowStacksAndSelects(); testZoneRowControlsMapToFields(); testZoneHitTestMisses(); + testSampleBandsStackAndElasticHero(); + testClusterRectsRunAndKnobCentering(); + testZoneSurfaceLayoutAnchors(); if (g_fail == 0) std::printf("editor_geometry: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp index 43467c0..2a6a934 100644 --- a/tests/test_instrument_drop.cpp +++ b/tests/test_instrument_drop.cpp @@ -7,7 +7,7 @@ // cross-artifact contract guard — the same pattern assignment_request_tests uses. #include "../src/core/wire/instrument_drop.h" -#include "../src/core/instrument/map/sample_map.h" // deserializeComponentState — the instrument's OWN reader +#include "../src/core/instrument/map/component_state_io.h" // deserializeComponentState — the instrument's OWN reader #include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — pins the per-channel class ID @@ -18,6 +18,7 @@ using namespace reasampler; using namespace reasampler::wire; +using namespace reasampler::instrument::map; // ComponentState + the codec (Q-W2v re-namespace) static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 1a7a96d..d0f68c2 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -23,6 +23,7 @@ // channel-count stride downmixToMono divides by). #include "../src/core/instrument/map/sample_map.h" +#include "../src/core/instrument/map/component_state_io.h" // the Q-W2v codec split (formats FROZEN; suite unchanged) #include #include @@ -36,7 +37,8 @@ using namespace reasampler; using namespace reasampler::instrument::engine; -using namespace reasampler::capture; // wav_trim (WavLayout) — sample_map re-exports live in reasampler until Q-W2v +using namespace reasampler::instrument::map; // sample_map + component_state_io (Q-W2v re-namespace) +using namespace reasampler::capture; // wav_trim (WavLayout) using namespace reasampler::model; static int g_fail = 0; diff --git a/tests/test_wire.cpp b/tests/test_wire.cpp index d2420b4..edb0354 100644 --- a/tests/test_wire.cpp +++ b/tests/test_wire.cpp @@ -9,9 +9,12 @@ // reference-bound std::string — the Cursor never outlives its buffer. #include "../src/core/wire/wire.h" +#include "../src/core/wire/bytes.h" // putLE / ByteReader — the ONE LE byte codec (Q-W2v, T4-20) +#include #include #include +#include using namespace reasampler; using namespace reasampler::wire; @@ -173,7 +176,56 @@ static void testParseUnsignedDecimal() { CHECK(!wire::parseUnsignedDecimal(std::string(40, '9'), v)); // long run cannot wrap } +// --- core/wire/bytes.h — the LE byte codec (Q-W2v, T4-20) -------------------- + +// putLE emits exactly the bytes the retired hand-rolled putU32le/putU64le emitted +// (LSB first, fixed width) — the FROZEN wire byte order. +static void testPutLEExactBytes() { + std::vector out; + wire::putLE(out, static_cast(0x0403'0201u)); + CHECK(out.size() == 4); + CHECK(out[0] == 0x01 && out[1] == 0x02 && out[2] == 0x03 && out[3] == 0x04); + out.clear(); + wire::putLE(out, static_cast(0x0807'0605'0403'0201ull)); + CHECK(out.size() == 8); + CHECK(out[0] == 0x01 && out[7] == 0x08); +} + +// ByteReader round-trips putLE output, and the double bit-cast is lossless. +static void testByteReaderRoundTrip() { + std::vector out; + wire::putLE(out, static_cast(7)); + wire::putLE(out, static_cast(wire::doubleToBits(-2.5))); + wire::putLE(out, static_cast(0xAB)); + wire::putLE(out, + static_cast(static_cast(-42))); // i64 image + wire::ByteReader r(out); + CHECK(r.peekU32() == 7); + CHECK(r.u32() == 7); + CHECK(wire::bitsToDouble(r.u64()) == -2.5); + CHECK(r.u8() == 0xAB); + CHECK(r.i64() == -42); + CHECK(r.ok); +} + +// Truncation latches ok=false and every subsequent read yields zero/empty — +// the partial-parse contract every codec consumer leans on. +static void testByteReaderLatchesOnTruncation() { + std::vector out; + wire::putLE(out, static_cast(9)); + out.pop_back(); // truncate mid-u32 + wire::ByteReader r(out); + CHECK(r.u32() == 0); + CHECK(!r.ok); + CHECK(r.u8() == 0); // latched: even an in-bounds width now fails + CHECK(r.str(1).empty()); + CHECK(r.peekU32() == 0); +} + int main() { + testPutLEExactBytes(); + testByteReaderRoundTrip(); + testByteReaderLatchesOnTruncation(); testPutFieldExactBytes(); testFieldRoundTripIncludingSeparators(); testLiteralMismatchFails(); From f0f91f76980b87d70630ae53996b71b412075f4a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:28:52 -0400 Subject: [PATCH 28/40] Q-W2v review follow-ups: golden full-blob fixture test, dead-local cleanup, bool-guard static_assert, explicit VelocityCurve qualification --- .../instrument/map/component_state_io.cpp | 2 +- src/core/wire/bytes.h | 2 +- .../instrument/editor_input_browse_zone.cpp | 1 - .../instrument/editor_paint_browse_zone.cpp | 1 - tests/test_component_state_io.cpp | 142 ++++++++++++++++++ 5 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index 2307c5f..bcec2b6 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -191,7 +191,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { const double amp = bitsToDouble(r.u64()); pts.push_back(VelocityPoint{vel, amp}); } - if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts)); + if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts)); } // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. diff --git a/src/core/wire/bytes.h b/src/core/wire/bytes.h index db4cff8..8b6c5fa 100644 --- a/src/core/wire/bytes.h +++ b/src/core/wire/bytes.h @@ -31,7 +31,7 @@ namespace reasampler::wire { // (cast at the call site, the established idiom: u32 for int, u64 for int64). template inline void putLE(std::vector& out, T v) { - static_assert(std::is_unsigned_v, "putLE takes the unsigned wire image"); + static_assert(std::is_unsigned_v && !std::is_same_v, "putLE takes the unsigned wire image"); for (std::size_t b = 0; b < sizeof(T); ++b) { out.push_back(static_cast((v >> (b * 8)) & 0xFF)); } diff --git a/src/shell/instrument/editor_input_browse_zone.cpp b/src/shell/instrument/editor_input_browse_zone.cpp index 063b592..1587c23 100644 --- a/src/shell/instrument/editor_input_browse_zone.cpp +++ b/src/shell/instrument/editor_input_browse_zone.cpp @@ -205,7 +205,6 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { const Rect back = zoneBackRect(w, h); if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } const Rect content = zoneContentArea(w, h); - const int pad = 8; Rect addR = zoneAddRect(content); if (contains(addR, x, y)) { // Add a narrow default zone for the picked capture (or the first visible sample as a diff --git a/src/shell/instrument/editor_paint_browse_zone.cpp b/src/shell/instrument/editor_paint_browse_zone.cpp index 2d99f7e..60c5d82 100644 --- a/src/shell/instrument/editor_paint_browse_zone.cpp +++ b/src/shell/instrument/editor_paint_browse_zone.cpp @@ -171,7 +171,6 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { } const Rect content = zoneContentArea(w, h); - const int pad = 8; // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip // with one bar per zone. Delete is a small × on the selected zone (keystroke also). diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index d84ad8c..0b4a618 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -101,6 +101,147 @@ static void testComponentStateRoundTrip() { } } +// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up, Q-W2v). testEnvelopePrefixBytesFrozen below +// only pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the +// mode byte (a field re-ordered or dropped inside the voice/gain/refs/guid/zone tail would +// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field +// family at once (two zones — one Trigger with every optional override set, one Gate with all +// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode +// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected +// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the +// round-trip test above) captured as the golden — so the byte layout itself becomes +// un-driftable, not just its first 5 bytes. +static void testGoldenFullBlobFixture() { + ComponentState in; + in.selectionId = "kick"; + in.channelMode = ChannelMode::Stereo; + in.channelModeExplicit = true; + in.lastConsumedAssignGeneration = 12345; + in.previewVelocity = 100; + in.voiceCount = 24; + in.voiceMode = VoiceMode::Mono; + in.monoTrigger = MonoTrigger::Legato; + in.masterGainLinear = 2.0; + in.instanceGuid = "guid-1234-5678-abcd"; + + SampleRefEntry kickRef; + kickRef.sampleId = "kick"; + kickRef.ref.relativePath = "bank/kick.wav"; + kickRef.ref.rootNote = 36; + kickRef.ref.loop.hasLoop = true; + kickRef.ref.loop.start = 1000; + kickRef.ref.loop.end = 5000; + kickRef.ref.channelCount = 2; + kickRef.displayName = "Kick Drum"; + in.sampleRefs.push_back(kickRef); + + SampleRefEntry snareRef; + snareRef.sampleId = "snare"; + snareRef.ref.relativePath = "bank/snare.wav"; + snareRef.ref.rootNote = 38; + snareRef.ref.loop.hasLoop = false; + snareRef.ref.loop.start = 0; + snareRef.ref.loop.end = 0; + snareRef.ref.channelCount = 1; + snareRef.displayName = "Snare"; + in.sampleRefs.push_back(snareRef); + + // Zone A: every optional field present, Trigger mode, non-flat velocity curve. + PerformanceZone zoneA; + zoneA.sampleId = "kick"; + zoneA.lowNote = 24; + zoneA.highNote = 60; + zoneA.rootOverride = 36; + SampleLoop loopA; + loopA.hasLoop = true; + loopA.start = 1000; + loopA.end = 5000; + zoneA.loopOverride = loopA; + zoneA.startPoint = 250; + zoneA.keyTrack = 0.5; + zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}); + zoneA.play.playMode = PlayMode::Trigger; + zoneA.play.adsr.attackSeconds = 0.01; + zoneA.play.adsr.holdSeconds = 0.05; + zoneA.play.adsr.decaySeconds = 0.02; + zoneA.play.adsr.sustainLevel = 0.8; + zoneA.play.adsr.releaseSeconds = 0.15; + zoneA.play.trigger.lengthFraction = 0.75; + zoneA.play.trigger.fadeInFrames = 100; + zoneA.play.trigger.fadeOutFrames = 200; + zoneA.play.pitchEngine = PitchEngine::Preserve; + zoneA.play.pitchEnv.enabled = true; + zoneA.play.pitchEnv.attackSeconds = 0.02; + zoneA.play.pitchEnv.decaySeconds = 0.03; + zoneA.play.pitchEnv.peakSemitones = 5.0; + in.map.zones.push_back(zoneA); + + // Zone B: every optional field absent, Gate mode, default flat velocity curve. + PerformanceZone zoneB; + zoneB.sampleId = "snare"; + zoneB.lowNote = 61; + zoneB.highNote = 90; + zoneB.keyTrack = 2.0; + zoneB.play.playMode = PlayMode::Gate; + zoneB.play.adsr.attackSeconds = 0.005; + zoneB.play.adsr.holdSeconds = 0.0; + zoneB.play.adsr.decaySeconds = 0.1; + zoneB.play.adsr.sustainLevel = 0.5; + zoneB.play.adsr.releaseSeconds = 0.2; + zoneB.play.pitchEngine = PitchEngine::Varispeed; + in.map.zones.push_back(zoneB); + + const std::vector bytes = serializeComponentState(in); + // clang-format off + static const std::uint8_t kGolden[] = { + 0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01, + 0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00, + 0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b, + 0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00, + 0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00, + 0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00, + 0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73, + 0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, + 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, + 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x07,0x00,0x00, + 0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00, + 0x00,0x3c,0x00,0x00,0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00, + 0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00, + 0x00,0x00,0x00,0x00,0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f, + 0xb8,0x1e,0x85,0xeb,0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40, + 0x7b,0x14,0xae,0x47,0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f, + 0x9a,0x99,0x99,0x99,0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f, + 0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00, + 0x00,0x00,0x50,0x40,0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00, + 0x00,0xc0,0x5f,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x05,0x00,0x00,0x00, + 0x73,0x6e,0x61,0x72,0x65,0x3d,0x00,0x00,0x00,0x5a,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xf0, + 0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7b,0x14,0xae,0x47,0xe1, + 0x7a,0x74,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xb9,0x3f,0x00,0x00,0x00,0x00,0x00, + 0x00,0xe0,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x40,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,0x00, + 0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, + }; + // clang-format on + CHECK(bytes.size() == sizeof(kGolden)); + if (bytes.size() == sizeof(kGolden)) { + bool same = true; + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (bytes[i] != kGolden[i]) { same = false; break; } + } + CHECK(same); + } +} + // The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in // either is a byte-format break the round-trip alone can't prove (both sides could // drift together). Pins the writer's absolute bytes. @@ -166,6 +307,7 @@ static void testPerformanceRoundTrip() { int main() { testComponentStateRoundTrip(); + testGoldenFullBlobFixture(); testEnvelopePrefixBytesFrozen(); testV1SelectionLift(); testTruncationDegradesCleanly(); From 09f7173db22bbde4f689852b363a05f3152bd949 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 10:56:11 -0400 Subject: [PATCH 29/40] =?UTF-8?q?Q-W3:=20main.cpp=20=E2=86=92=20pointers+e?= =?UTF-8?q?ntry+dispatch=20via=204=20capture=20hoists;=20one=20pure=20wav?= =?UTF-8?q?=5Fcodec=20RIFF=20owner;=20ICaptureBackend=20deleted;=20capture?= =?UTF-8?q?=5Frealtime=20rename=20+=20finalize=20split;=20shared=20stampCa?= =?UTF-8?q?ptureSample;=20makeUniqueTag=20gains=20monotonic=20counter=20(f?= =?UTF-8?q?ixes=20same-second=20batch=20collisions).=2060/60=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- CMakeLists.txt | 71 +- CONTEXT-ARCHIVE.md | 6 +- src/app/main.cpp | 1356 +---------------- src/core/capture/capture_paths.cpp | 112 +- src/core/capture/capture_paths.h | 34 +- ...altime_record.cpp => capture_realtime.cpp} | 7 +- .../{realtime_record.h => capture_realtime.h} | 9 +- src/core/capture/wav_codec.cpp | 333 ++++ src/core/capture/wav_codec.h | 172 +++ src/core/capture/wav_trim.cpp | 160 -- src/core/capture/wav_trim.h | 110 +- src/ingest.cpp | 74 +- src/shell/capture/capture.cpp | 149 +- src/shell/capture/capture.h | 111 +- src/shell/capture/capture_batch.cpp | 523 +++++++ src/shell/capture/capture_batch.h | 32 + src/shell/capture/capture_orchestrator.cpp | 487 ++++++ src/shell/capture/capture_orchestrator.h | 73 + .../capture/capture_realtime_finalize.cpp | 252 +++ src/shell/capture/capture_realtime_finalize.h | 42 + ...ealtime.cpp => capture_realtime_shell.cpp} | 304 +--- src/shell/capture/realtime_lifecycle.cpp | 101 ++ src/shell/capture/realtime_lifecycle.h | 56 + src/shell/capture/scope_resolve.cpp | 242 +++ src/shell/capture/scope_resolve.h | 71 + tests/test_capture_paths.cpp | 220 +-- ...e_record.cpp => test_capture_realtime.cpp} | 9 +- .../{test_wav_trim.cpp => test_wav_codec.cpp} | 280 +++- 29 files changed, 2972 insertions(+), 2426 deletions(-) rename src/core/capture/{realtime_record.cpp => capture_realtime.cpp} (95%) rename src/core/capture/{realtime_record.h => capture_realtime.h} (96%) create mode 100644 src/core/capture/wav_codec.cpp create mode 100644 src/core/capture/wav_codec.h delete mode 100644 src/core/capture/wav_trim.cpp create mode 100644 src/shell/capture/capture_batch.cpp create mode 100644 src/shell/capture/capture_batch.h create mode 100644 src/shell/capture/capture_orchestrator.cpp create mode 100644 src/shell/capture/capture_orchestrator.h create mode 100644 src/shell/capture/capture_realtime_finalize.cpp create mode 100644 src/shell/capture/capture_realtime_finalize.h rename src/shell/capture/{capture_realtime.cpp => capture_realtime_shell.cpp} (67%) create mode 100644 src/shell/capture/realtime_lifecycle.cpp create mode 100644 src/shell/capture/realtime_lifecycle.h create mode 100644 src/shell/capture/scope_resolve.cpp create mode 100644 src/shell/capture/scope_resolve.h rename tests/{test_realtime_record.cpp => test_capture_realtime.cpp} (97%) rename tests/{test_wav_trim.cpp => test_wav_codec.cpp} (55%) diff --git a/CLAUDE.md b/CLAUDE.md index 745d06c..2b8312f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. **REAPER-facing shells:** -- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. +- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. - `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** - `bank_panel` — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. - `persist` — project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, and writing-version stamp. A `projectconfig` hook triggers a deferred session reload on undo/redo. Hosts the prune dry-run and full-set orphan queries; supplies `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core. **pS-usage:** `scanPruneOrphans` unions instance usage via `usage_scan`; `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. diff --git a/CMakeLists.txt b/CMakeLists.txt index 6449117..f643210 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -310,30 +310,40 @@ add_library(prune_button STATIC src/core/ui/prune_button.cpp) target_include_directories(prune_button PUBLIC src) # --------------------------------------------------------------------------- -# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record -# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, -# wet/dry -> tap point, and the recorded-file -> Sample mapping. Split out so -# the fiddly record-mode bit values + Sample population are unit-tested outside -# the DAW; the transport/temp-track/send/file-move recipe stays in capture.cpp. +# 2g) Pure capture_realtime library — NO REAPER, NO SWELL. (Renamed from +# realtime_record in Q-W3 — the Q-9 naming rider: pure module takes the stem, +# the shell takes the suffix, matching drag_out <-> drag_out_win.) The M8 +# realtime-record logic: capture scope + FX-tap point -> I_RECMODE / +# I_RECMODE_FLAGS values, wet/dry -> tap point, the recorded-file -> Sample +# mapping, and the async record-phase state machine. Split out so the fiddly +# record-mode bit values + Sample population + phase transitions are +# unit-tested outside the DAW; the transport/temp-track/send recipe stays in +# capture_realtime_shell.cpp (+ the file-side capture_realtime_finalize.cpp). # Depends on bank_model for the pure Sample / SourceMode types. # --------------------------------------------------------------------------- -add_library(realtime_record STATIC src/core/capture/realtime_record.cpp) -target_include_directories(realtime_record PUBLIC src) -target_link_libraries(realtime_record PUBLIC bank_model) +add_library(capture_realtime STATIC src/core/capture/capture_realtime.cpp) +target_include_directories(capture_realtime PUBLIC src) +target_link_libraries(capture_realtime PUBLIC bank_model) # --------------------------------------------------------------------------- -# 2h) Pure wav_trim library — NO REAPER, NO SWELL. The realtime tail's (T2) PCM -# decay-scan trim needs to TRUNCATE the recorded 32-bit-float WAV at a frame -# boundary without corrupting the RIFF container. This module holds the fiddly, -# easy-to-get-wrong part unit-tested outside the DAW: parse the WAV geometry -# (fmt/data chunk walk + 32-bit-float verification), extract the tail-region -# floats to scan, and compute the truncate plan (kept byte length + the two -# patched RIFF/data size fields). The file read/write/truncate I/O stays in the -# realtime shell. Depends on peaks for the AudioSample float alias. +# 2h) Pure wav_codec library — NO REAPER, NO SWELL. The ONE owner of the WAV/RIFF +# byte format (Q-W3, audit §4e: T2-08 / T4-10 / T4-23 consolidation): the RIFF +# chunk walker + layout parse (formerly wav_trim), the tail-trim truncate plan + +# size-field patch (formerly duplicated in capture_realtime), the float32 WAV +# build (formerly hand-rolled in ingest), and the WAV-aware content hashes +# (formerly in capture_paths). The dedup-by-hash and null-test invariants rest +# on this one implementation. File I/O stays in the shells. Depends on peaks +# for the AudioSample float alias. +# `wav_trim` remains as a TRANSITIONAL alias (forwarding header + INTERFACE +# target) so the Q-W2v-owned TUs (sample_map, VST editor/processor) build +# untouched in their parallel wave; retire both once Q-W2v lands. # --------------------------------------------------------------------------- -add_library(wav_trim STATIC src/core/capture/wav_trim.cpp) -target_include_directories(wav_trim PUBLIC src) -target_link_libraries(wav_trim PUBLIC peaks) +add_library(wav_codec STATIC src/core/capture/wav_codec.cpp) +target_include_directories(wav_codec PUBLIC src) +target_link_libraries(wav_codec PUBLIC peaks) + +add_library(wav_trim INTERFACE) +target_link_libraries(wav_trim INTERFACE wav_codec) # --------------------------------------------------------------------------- # 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity @@ -649,9 +659,9 @@ add_executable(tail_control_tests tests/test_tail_control.cpp) target_link_libraries(tail_control_tests PRIVATE tail_control) add_test(NAME tail_control_tests COMMAND tail_control_tests) -add_executable(realtime_record_tests tests/test_realtime_record.cpp) -target_link_libraries(realtime_record_tests PRIVATE realtime_record) -add_test(NAME realtime_record_tests COMMAND realtime_record_tests) +add_executable(capture_realtime_tests tests/test_capture_realtime.cpp) +target_link_libraries(capture_realtime_tests PRIVATE capture_realtime) +add_test(NAME capture_realtime_tests COMMAND capture_realtime_tests) add_executable(bank_book_tests tests/test_bank_book.cpp) target_link_libraries(bank_book_tests PRIVATE bank_book) @@ -661,9 +671,9 @@ add_executable(slot_map_tests tests/test_slot_map.cpp) target_link_libraries(slot_map_tests PRIVATE slot_map json) add_test(NAME slot_map_tests COMMAND slot_map_tests) -add_executable(wav_trim_tests tests/test_wav_trim.cpp) -target_link_libraries(wav_trim_tests PRIVATE wav_trim) -add_test(NAME wav_trim_tests COMMAND wav_trim_tests) +add_executable(wav_codec_tests tests/test_wav_codec.cpp) +target_link_libraries(wav_codec_tests PRIVATE wav_codec) +add_test(NAME wav_codec_tests COMMAND wav_codec_tests) add_executable(owned_manifest_tests tests/test_owned_manifest.cpp) target_link_libraries(owned_manifest_tests PRIVATE owned_manifest) @@ -1083,8 +1093,13 @@ set(LICE_SRC add_library(reaper_reasampler MODULE src/app/main.cpp src/shell/capture/capture.cpp - src/shell/capture/capture_realtime.cpp - src/core/capture/realtime_record.cpp + src/shell/capture/capture_orchestrator.cpp + src/shell/capture/capture_batch.cpp + src/shell/capture/scope_resolve.cpp + src/shell/capture/realtime_lifecycle.cpp + src/shell/capture/capture_realtime_shell.cpp + src/shell/capture/capture_realtime_finalize.cpp + src/core/capture/capture_realtime.cpp src/persist.cpp src/shell/panel/panel_audition.cpp src/shell/panel/panel_bank_ops.cpp @@ -1123,7 +1138,7 @@ add_library(reaper_reasampler MODULE src/core/ui/card_drag.cpp src/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest 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) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest 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) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/CONTEXT-ARCHIVE.md b/CONTEXT-ARCHIVE.md index a2f4207..3e41e15 100644 --- a/CONTEXT-ARCHIVE.md +++ b/CONTEXT-ARCHIVE.md @@ -25,8 +25,10 @@ Pure (no REAPER types, fully unit-tested): format, so this is simpler, testable, and dependency-free. REAPER-facing: -- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and - `RealtimeRecordBackend`. Input: a `CaptureRequest` (capture scope — item or +- `capture` — two CONCRETE backends, `OfflineRenderBackend` (synchronous) and + `RealtimeRecordBackend` (async begin/tick/abort); no shared interface (the + former `ICaptureBackend` was deleted in Q-W3 — T4-26: one deriver, zero + polymorphic call sites). Input: a `CaptureRequest` (capture scope — item or track, time range, tail, SR/bit-depth/channels, output path). Output: a finished file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX *scope* (not a wet/dry dial) is the control — the pure `render_settings` module diff --git a/src/app/main.cpp b/src/app/main.cpp index 92f1daf..7200eaa 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // // This file is the entire contract between REAPER and the extension: @@ -14,6 +13,11 @@ // Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates // storage for those global pointers. Every other .cpp includes // reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. +// +// Since Q-W3 this TU is ONLY pointers + entry + dispatch: the capture +// orchestration it used to carry lives in shell/capture/ (capture_orchestrator / +// capture_batch / scope_resolve / realtime_lifecycle). The registration blocks +// below are slated for Q-W6's registration table. #define REAPERAPI_IMPLEMENT #include "reaper_plugin.h" @@ -21,30 +25,24 @@ #include #include -#include #include -#include - #include #include "actions.h" -#include "core/version/app_version.h" -#include "core/model/bank_model.h" -#include "shell/panel/panel_bank_ops.h" // selection read seam (insert action) -#include "shell/panel/panel_input.h" // bankPanelRefresh / project-load notify / tail seam -#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/shutdown) -#include "core/capture/batch_capture.h" -#include "shell/capture/capture.h" +#include "core/capture/render_settings.h" // captureActionTable +#include "core/version/app_version.h" // channelCommandId / channelActionName / appVersion #include "ingest.h" -#include "shell/capture/insert.h" #include "persist.h" -#include "core/model/provenance.h" -#include "shell/capture/provenance_shell.h" -#include "core/capture/render_settings.h" -#include "shell/capture/track_guid.h" -#include "shell/view/view.h" +#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/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded +#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) +#include "shell/view/view.h" // reconcileManagedLanes / applyMode -#include // project-dir derivation for provenance parent resolution +namespace capture = reasampler::capture; +using reasampler::version::channelActionName; +using reasampler::version::channelCommandId; // Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action // mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list @@ -65,7 +63,7 @@ static std::deque g_idStore; // Appended-to only during startup registration and read on unload; never cleared until // process exit, and deque guarantees the returned pointer stays valid. static const char* internCmdId(const std::string& suffix) { - g_idStore.push_back(reasampler::channelCommandId(suffix)); + g_idStore.push_back(channelCommandId(suffix)); return g_idStore.back().c_str(); } @@ -76,7 +74,8 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct // ---- Capture action family (two FX scopes) --------------------------------- // Two bindable SCOPE actions from captureActionTable() (render_settings, pure): // capture item / track. Each infers its range (razor-else-time) and enforces the -// FX-scope invariant via FX-bypass-around-render (FxBypassGuard): +// FX-scope invariant via FX-bypass-around-render (FxBypassGuard, now in +// capture_orchestrator): // Item -> take/item FX only (bypass the item's track + ancestors + master). // Track -> item FX + track's own FX (bypass ancestors + master). // There is NO master scope — to capture the master you render a track. (The master @@ -150,25 +149,19 @@ static int g_cmdCaptureTrackRealtime = 0; // Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string // (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED // sample from its recorded source's current state and updates the Sample in place — -// BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no- -// provenance / vanished-source / drift cases to the console (a direct response to an -// explicit action, allowed by the console policy). +// BANK-ONLY, never places on the timeline (load-bearing principle). static int g_cmdRecaptureFromSource = 0; // Command id for the S8 "capture selected item / time-selection into bank + assign" // action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline -// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same -// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request -// so the active sampler instance plays the just-captured sample on its next reload. NEVER -// inserts a timeline item (the capture/placement separation holds; assign is a bank-index -// + instance-selection act). Lives in the capture family (not the ingest family) because -// it leans on main.cpp's capture render machinery, which is not exposed cross-module. +// Item-scope capture path (RunCapture) verbatim, then writes an S8 assignment request. +// Lives in the capture family (not the ingest family) because it leans on the capture +// render machinery (capture_orchestrator). static int g_cmdCaptureItemAssign = 0; // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. -// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user -// who started a long capture can bail without waiting for the range end or hunting for -// the transport-stop. No-op (with a note) when nothing is in flight. +// Aborts the in-flight realtime capture (stop + restore, non-destructive). No-op +// (with a note) when nothing is in flight. static int g_cmdCancelRealtime = 0; // Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand @@ -186,89 +179,6 @@ static int g_cmdShowVersion = 0; // with the .rpp. Replaces the M3 session-only g_bank. static reasampler::ReaSamplerSession g_session; -// --- M8 in-flight realtime capture (async, timer-driven) -------------------- -// A realtime record spans many timer ticks (it takes end-start wall-clock seconds -// and must NOT block REAPER's UI). The action STARTS it (g_rtBackend.begin), which -// returns immediately with the in-flight state owned here; OnTimer drives it -// (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is -// cleared. Non-null == a capture is in progress (used to reject a second one, and to -// abort on project switch / unload). -static reasampler::RealtimeRecordBackend g_rtBackend; -static reasampler::RealtimeCaptureHandle g_rtCapture; - -// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets -// OnTimer detect a project switch mid-capture and abort+restore rather than leak the -// temp track/arm/transport into or across projects. Only meaningful when -// g_rtCapture != nullptr. -static ReaProject* g_rtCaptureProject = nullptr; - -// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the -// Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2), -// persist + MarkProjectDirty. Shared by the tick-completion path and the abort -// paths. On a non-Ok result, logs the failure only. -static void CommitRealtimeResult(const reasampler::CaptureResult& res) -{ - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); - return; - } - g_session.bank().add(res.sample); - // B-cap: record the file the capture created in the owned-file manifest, at the same - // point the Sample is added and before the same persist. Recorded regardless of the - // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the - // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). - g_session.owned().add(res.sample.relativePath); - // S9: a capture add changes what a live instance could play (a new sample landed in the - // active bank) -> bump before the persist so the stamped generation refreshes instances. - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) -} - -// Advance any in-flight realtime capture one tick. Cheap when none is running (a -// null check) and fast even mid-record (tick() only reads the transport until the -// terminal tick). Detects a project switch mid-capture and aborts+restores so the -// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so -// poll's project-switch handling sees a cleaned-up project. -static void DriveRealtimeCapture() -{ - if (!g_rtCapture) return; - - // Project switch guard: if the active project is no longer the one the capture - // belongs to, a new/other project became active mid-record — abort + restore - // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize - // into the new project. - ReaProject* active = EnumProjects(-1, nullptr, 0); - if (active != g_rtCaptureProject) - { - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - // Only commit if the ORIGINAL project is still open and active would be it — - // on a switch we restored into the original but must not persist into the - // now-active foreign project. Log the outcome without persisting. On a Failed - // abort surface abort()'s own message — it distinguishes a clean tab-switch - // abort from the closed-project DROP (the captured project was closed mid-record, - // review §1: nothing restored because the pointers were already freed). - if (r.status == reasampler::RealtimeTickStatus::Done) - ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " - "captured audio restored into the original project; not " - "persisted to avoid crossing projects.\n"); - else - ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " + - r.result.message + "\n").c_str()); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; - return; - } - - reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture); - if (r.status == reasampler::RealtimeTickStatus::InProgress) return; - - // Terminal (Done or Failed): commit/log and drop the in-flight state. - CommitRealtimeResult(r.result); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; -} - // The timer callback REAPER runs periodically (registered via "timer"). It only // forwards to the session poll — cheap per tick (reads the active project id and // its .rpp path, acts only on a change). @@ -276,7 +186,9 @@ static void OnTimer() { // Advance any in-flight realtime capture FIRST, so a project switch is caught and // the capture torn down/restored before session.poll() reacts to that switch. - DriveRealtimeCapture(); + // LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER + // TEST — the cross-TU drive call is made only when a capture is in flight. + if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); g_session.poll(); @@ -361,1155 +273,6 @@ static project_config_extension_t g_projectConfig{ nullptr, // userData }; -// --- Scope-action source resolution ----------------------------------------- -// The three scope actions (item / track / master) each resolve to (1) an exact -// render range in project seconds — razor-else-time, inferred here — and (2) the -// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads -// are non-destructive: selection, razor, and time selection are read, never -// mutated. Returning false means "nothing to capture" (empty selection / no -// range); the caller reports it and writes nothing. - -// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample -// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the -// selected tracks (Track scope). -struct ResolvedSource -{ - double startSeconds = 0.0; - double endSeconds = 0.0; - std::vector sourceTracks; // item-owning tracks / selected tracks - std::vector trackGuids; // canonical GUIDs of sourceTracks -}; - -// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, -// isLoop=false) reads the current time selection. -static bool resolveTimeSelection(double& start, double& end) -{ - start = 0.0; end = 0.0; - GetSet_LoopTimeRange(false, false, &start, &end, false); - return end > start; -} - -// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of -// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), -// and returns the union bound. Reads only — never clears the razor selection. -// Returns false when no track-audio razor area exists on any track. -static bool resolveRazorRange(double& start, double& end) -{ - std::vector allRanges; - const int n = CountTracks(nullptr); - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetTrack(nullptr, i); - if (!tr) continue; - std::vector buf(8192, '\0'); - if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) - continue; - std::vector ranges = - reasampler::parseRazorEdits(std::string(buf.data())); - for (auto& r : ranges) allRanges.push_back(r); - } - if (allRanges.empty()) return false; - reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges); - start = u.startSeconds; - end = u.endSeconds; - return end > start; -} - -// Infers the render RANGE for any scope: razor union when a razor area is present, -// else the time selection (pure inferRangeSource decides which). Orthogonal to -// scope. Returns false (with a reason) when neither yields a non-empty range. -static bool resolveRange(double& start, double& end, std::string& why) -{ - double rzStart = 0.0, rzEnd = 0.0; - const bool hasRazor = resolveRazorRange(rzStart, rzEnd); - if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor) - { - start = rzStart; end = rzEnd; - return true; // resolveRazorRange already verified end > start - } - if (resolveTimeSelection(start, end)) return true; - why = "make a razor area or a time selection first"; - return false; -} - -// Current project's directory (parent of its .rpp), forward-slashed, no trailing -// slash — the same derivation capture.cpp does internally, needed here so M10 can -// resolve the bank's relative paths to absolute for parent detection. Empty for an -// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank -// file resolve empty -> no false parentage. Read-only; mutates nothing. -static std::string currentProjectDir() -{ - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - const std::string rpp(buf.data()); - if (rpp.empty()) return {}; - namespace fs = std::filesystem; - std::string dir = fs::path(rpp).parent_path().string(); - for (char& c : dir) if (c == '\\') c = '/'; - if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); - return dir; -} - -// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the -// pure provenance module does not depend on render_settings). -static reasampler::ProvenanceScope provenanceScopeFor(reasampler::CaptureScope scope) -{ - return scope == reasampler::CaptureScope::Item ? reasampler::ProvenanceScope::Item - : reasampler::ProvenanceScope::Track; -} - -// Builds the M10 provenance for a capture IF it genuinely resamples from a bank -// sample, else returns nullopt (the common, non-resample case). Detection rule -// (stated honestly): the capture's source item media file(s) must all resolve, by -// exact normalized absolute path, to ONE bank sample's file (detectParent). On a -// match, records that sample's id as the parent plus a THIN capture-recipe -// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels + -// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from -// source" can replay the request and report drift. NEVER a serialized chain to -// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per -// selected item, combined in item order; Track scope reads the track FX chain. -static std::optional buildCaptureProvenance( - const reasampler::CaptureRequest& req, - reasampler::CaptureScope scope, - const ResolvedSource& src) -{ - const std::string projectDir = currentProjectDir(); - const std::vector bankFiles = - reasampler::bankFileRefs(g_session.book(), projectDir); - - // The "what audio is being captured" source set depends on scope: item scope uses - // the SELECTED items (the user picked them); track scope uses the range-overlapping - // items ON the source tracks (the user picked the track, not the item). - const std::vector sourceFiles = - scope == reasampler::CaptureScope::Item - ? reasampler::selectedItemSourceFiles() - : reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds, - req.endSeconds); - - const std::optional parentId = - reasampler::detectParent(sourceFiles, bankFiles); - if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance - - reasampler::CaptureRecipe recipe; - recipe.scope = provenanceScopeFor(scope); - recipe.sourceMode = static_cast(req.sourceMode); - recipe.startSeconds = req.startSeconds; - recipe.endSeconds = req.endSeconds; - recipe.tailMode = static_cast(req.tailMode); - recipe.tailMs = req.tailMs; - recipe.sampleRate = req.sampleRate; - recipe.channelCount = req.channelCount; - recipe.trackGuids = req.trackGuids; - // The in-scope FX-chain identity: - // Track scope — per-track chains combined in track order (TrackFX_*). - // Item scope — per-item active-take chains combined in item order (TakeFX_*); - // the owning track's FX chain is OUT OF SCOPE for an item capture and must - // not be fingerprinted here (it is bypassed during render, not heard). - if (scope == reasampler::CaptureScope::Item) { - const int n = CountSelectedMediaItems(nullptr); - std::vector items; - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (it) items.push_back(it); - } - recipe.fxChainIdentity = reasampler::fxChainIdentityForItems(items); - } else { - std::vector perTrack; - perTrack.reserve(src.sourceTracks.size()); - for (MediaTrack* tr : src.sourceTracks) - perTrack.push_back(reasampler::fxChainIdentityForTrack(tr)); - recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack); - } - - reasampler::Provenance prov; - prov.parentSampleId = *parentId; - prov.fxChainSnapshot = reasampler::buildFingerprint(recipe); - return prov; -} - -// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. -static bool collectSelectedTracks(ResolvedSource& out) -{ - const int n = CountSelectedTracks(nullptr); // nullptr = active project - if (n <= 0) return false; - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetSelectedTrack(nullptr, i); - if (!tr) continue; - out.sourceTracks.push_back(tr); - std::string g = reasampler::guidString(tr); - if (!g.empty()) out.trackGuids.push_back(std::move(g)); - } - return !out.sourceTracks.empty(); -} - -// Collects the tracks that own the selected items (Item scope) into -// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an -// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning -// track (SDK header, verify). GUIDs recorded for provenance. -static bool collectSelectedItemTracks(ResolvedSource& out) -{ - const int n = CountSelectedMediaItems(nullptr); - if (n <= 0) return false; - for (int i = 0; i < n; ++i) - { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (!it) continue; - MediaTrack* tr = GetMediaItem_Track(it); - if (!tr) continue; - // Dedup: several selected items can share a track. - bool seen = false; - for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; } - if (seen) continue; - out.sourceTracks.push_back(tr); - std::string g = reasampler::guidString(tr); - if (!g.empty()) out.trackGuids.push_back(std::move(g)); - } - return !out.sourceTracks.empty(); -} - -// Resolves the source for a scope: the selection tracks (item/track), plus the -// inferred range. Returns false with a reason on nothing to do. -static bool ResolveScopeSource(reasampler::CaptureScope scope, - ResolvedSource& out, std::string& why) -{ - using reasampler::CaptureScope; - switch (scope) - { - case CaptureScope::Item: - if (!collectSelectedItemTracks(out)) { - why = "select at least one media item"; return false; - } - break; - case CaptureScope::Track: - if (!collectSelectedTracks(out)) { - why = "select at least one track"; return false; - } - break; - } - return resolveRange(out.startSeconds, out.endSeconds, why); -} - -// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) -- -// For every track a scope must NOT hear the FX of, this ALSO neutralizes that -// track's fader gain AND its full pan chain (pan/width/law/mode) for the render — -// because a Track/Item capture renders via master and would otherwise sum through -// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan -// coloring into the file (Daniel: the capture is likely re-routed through that -// same chain later, so parent/master level and pan must not be baked in). The -// neutralize set is IDENTICAL to the FX-bypass set: -// Item -> own track + all ancestors + master (take vol/pan kept: item content). -// Track -> all ancestors + master (selected track's OWN vol/pan kept). -// (Master is a bypass TARGET for both scopes — never a scope of its own.) -// -// Per track in that set we snapshot & set the full parent-chain-independence set, -// so a Track/Item capture is uncolored by the parent/folder/master it renders -// through — no FX, no fader, and no pan/width/law/mode coloring: -// I_FXEN -> 0 (FX bypassed; SDK ~2194) -// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB") -// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1") -// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1", -// 1.0 = full width = no narrowing/collapse) -// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain) -// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual") -// All are restored to their ORIGINAL values on EVERY exit path (RAII). -// -// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes -// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan) -// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR -// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the -// header does not state as such. Rather than snapshot two more mode-conditional -// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the -// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the -// original mode. This fully neutralizes pan for every original mode with no -// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.) -// -// Structurally non-destructive: no takes, no items, no project restructuring — -// only transient FX-enable + trim-volume toggles, always restored. -class FxBypassGuard -{ -public: - // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose - // ancestor chains (walked via GetParentTrack) + the master are bypassed per the - // plan. proj is the active project (for GetMasterTrack). - FxBypassGuard(reasampler::CaptureScope scope, - const std::vector& sourceTracks, - ReaProject* proj) - { - const reasampler::FxBypassPlan plan = reasampler::fxBypassPlanFor(scope); - - for (MediaTrack* tr : sourceTracks) - { - if (!tr) continue; - if (plan.bypassSelfFx) bypass(tr); - if (plan.bypassAncestorFx) - { - // Walk parents to the top: GetParentTrack returns the immediate - // parent (folder) track, nullptr at the outermost level (SDK - // header ~2407). The master is NOT returned here — handled below. - for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p)) - bypass(p); - } - } - if (plan.bypassMaster) - { - // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass() - // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it - // just like any other in-scope track; only the master's summing/routing - // topology (the mix bus itself) remains — that is not a per-track param. - if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); - } - } - - ~FxBypassGuard() - { - // Restore in reverse for symmetry (order is not load-bearing — each track - // appears once, snapshots are independent). EVERY snapshotted param is - // restored to its ORIGINAL value on this (every) exit path. Restore - // I_PANMODE before the pan values so any mode-conditional params (e.g. dual - // pan) settle under the original mode. - for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) - { - SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); - SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol); - SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode); - SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan); - SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width); - SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw); - } - } - - FxBypassGuard(const FxBypassGuard&) = delete; - FxBypassGuard& operator=(const FxBypassGuard&) = delete; - -private: - // One snapshot per bypassed track: all params we neutralize, at their originals. - // panmode captures I_PANMODE so we can force stereo-pan for the render and put - // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when - // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to - // touch or guess neutral values for them. - struct Snap - { - MediaTrack* track; - double fxen; - double vol; - double pan; - double width; - double panlaw; - double panmode; - }; - std::vector snapshots_; - - // Snapshot every neutralized param once per track (dedup: an ancestor shared by - // two selected tracks must be restored to its ORIGINAL values, not to a - // re-snapshot of the already-neutralized state), then read ALL originals, push - // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain. - void bypass(MediaTrack* tr) - { - for (const Snap& s : snapshots_) if (s.track == tr) return; // already done - // Read ALL originals first (atomic snapshot), then push, then neutralize. - const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); - const double vol = GetMediaTrackInfo_Value(tr, "D_VOL"); - const double pan = GetMediaTrackInfo_Value(tr, "D_PAN"); - const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH"); - const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW"); - const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE"); - snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode}); - SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) - SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226) - SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231) - SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227) - SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228) - SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232) - } -}; - -// Renders one CaptureRequest through the offline backend under the scope's -// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and -// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE -// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the -// render, and fully restored on every path (RAII). Non-destructive; touches no -// timeline item (load-bearing principle) — it writes a file only. -static reasampler::CaptureResult renderOffline( - reasampler::CaptureScope scope, - const std::vector& sourceTracks, - const reasampler::CaptureRequest& req) -{ - ReaProject* proj = EnumProjects(-1, nullptr, 0); - FxBypassGuard fxGuard(scope, sourceTracks, proj); - reasampler::OfflineRenderBackend backend; - return backend.capture(req); -} - -// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, -// and adds the resulting Sample to the ACTIVE bank + records the created file in the -// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture: -// right after; batch: once at the end) so a batch does not write ext state N times. -// -// Provenance is read from the LIVE selection here, so a batch that transiently -// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies -// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and -// provenance scope. Returns the backend's CaptureResult (status + message) so the -// caller can report success/failure. Load-bearing principle holds: writes a file + -// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the -// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), -// and the backend restores every RENDER_* setting. -// -// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id -// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 -// capture+assign path can target the sample actually in the bank. Batch callers ignore -// it; the plain capture actions are unaffected. -static reasampler::CaptureResult captureAndIndexOne( - reasampler::CaptureScope scope, - const ResolvedSource& src, - const std::string& baseName, - double startSeconds, - double endSeconds) -{ - // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action - // variant: the capture actions apply whatever the panel is set to. Default is None - // (exact bounds / byte-identical to today) until the user opts in via the toggle. - const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::sourceModeForScope(scope); - req.startSeconds = startSeconds; // exact bounds — no rounding - req.endSeconds = endSeconds; - req.wetDry = 1.0; // wet post the FX left enabled by the scope - req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle - req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto - req.sampleRate = 0; // follow project rate - req.channelCount = 2; - req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither - req.baseName = baseName; - req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) - - // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain — - // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and - // the source selection is still live here. Returns nullopt unless this capture - // genuinely resamples from a bank sample (detectParent). Read-only. - const std::optional prov = - buildCaptureProvenance(req, scope, src); - - // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain - // neutralized for the render, fully restored on every path). Writes a file only. - reasampler::CaptureResult res = renderOffline(scope, src.sourceTracks, req); - if (res.status != reasampler::CaptureStatus::Ok) - return res; - - // Stamp provenance onto the captured Sample (only set when this was a genuine - // resample-from-sample; otherwise the optional stays empty, per M1's contract). - res.sample.provenance = prov; - - // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The - // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can - // target the sample actually in the bank (the existing entry on a collapse). - const reasampler::AddResult addResult = g_session.bank().add(res.sample); - // B-cap: record the created file in the owned-file manifest, at the same point the - // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse - // still WROTE a file the tool owns, and the manifest dedups a repeat path itself - // (Phase R prune reconciles manifest vs index later). - g_session.owned().add(res.sample.relativePath); - - // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new - // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a - // Collapsed (the file we just rendered deduped onto an already-present sample — assign - // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged. - if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty()) - { - if (const reasampler::Sample* existing = - g_session.bank().findByHash(res.sample.contentHash)) - res.sample.id = existing->id; - } - return res; -} - -// Runs one capture-action-table row: resolve its scope source + range, render + add + -// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle -// holds structurally — this path writes a file + a bank index entry ONLY; it never -// calls InsertMedia or touches the arrange/timeline. -// Returns the bank-index id of the sample the capture landed on: the newly-added id on a -// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign -// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange -// capture+assign path reads this to write an assignment request; the plain capture actions -// ignore it (their behaviour is unchanged — capture still writes a file + index entry only). -static std::string RunCapture(const reasampler::CaptureActionDef& def) -{ - ResolvedSource src; - std::string why; - if (!ResolveScopeSource(def.scope, src, why)) - { - ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); - return {}; - } - - reasampler::CaptureResult res = - captureAndIndexOne(def.scope, src, def.baseName, src.startSeconds, src.endSeconds); - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); - return {}; - } - - // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE - // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). - // Persist the updated book AND manifest into the active project's ext state (the - // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and - // travels with the .rpp. saveToActiveProject also clears the retired legacy key and - // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. - // S9: a capture add is a bank-content change -> bump before the persist so an assigned - // live instance refreshes hands-free (the S8 capture+assign path builds on this). - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - - // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne - // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a - // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above). - return res.sample.id; -} - -// S8 arrange ingest: capture the selected item / time-selection into the active bank -// (reusing the Item-scope capture path verbatim) and, on success, write an assignment -// request so the active sampler instance plays the new sample on its next reload. The -// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER -// inserts a timeline item (load-bearing principle); the only addition here is the -// bank-index-id -> assignment-request write after the sample lands. If the capture -// failed / no-op'd (empty id), no assignment is written (nothing to assign). -// -// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the -// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are -// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically. -// An undo that removes the captured sample also clears the assign_request that named it, -// preventing a stale request from pointing at a removed sample. The block uses the house -// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero -// flag) matching the bank-op family in actions.cpp. -static void RunCaptureItemAssign() -{ - // Reuse the Item-scope def from the capture table (index 0) — same range logic, same - // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. - Undo_BeginBlock2(nullptr); - - const std::string sampleId = - RunCapture(reasampler::captureActionTable()[0]); - if (sampleId.empty()) - { - // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. - Undo_EndBlock2(nullptr, "", 0); - return; - } - - // Assign inside the same block so undo clears both keys together. - reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId); - Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", - UNDO_STATE_MISCCFG); - - reasampler::bankPanelRefresh(); - ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " - "instance.\n"); -} - -// --- M11: batch capture (per selected item / per razor area) ---------------- -// -// One action fires N captures — one bank sample per selected item (item scope) or per -// razor area (track scope, each area's own range). Each individual capture honors every -// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan -// neutralize, relative paths, channel preservation) and M10 provenance stamping applies -// per capture where its detection rule matches. The load-bearing principle holds: each -// unit writes a file + a bank index entry ONLY; nothing lands in the arrange. -// -// Per-unit FILE NAMING: the offline backend's unique tag is 1-second-granular. The -// per-item render already takes real wall-clock time (REAPER's offline-render dialog per -// unit), so consecutive units naturally land in distinct seconds; belt-and-braces, each -// unit's baseName also carries its ordinal ("item-1", "item-2", ...) so two units are -// never asked to write the same stem within one batch. (Residual, DAW-verify: two BATCHES -// fired within the same wall-clock second with identical ordinals could still collide — -// unreachable in practice given the per-unit render latency, noted for completeness.) - -// RAII snapshot/restore of the project's media-item selection. Batch item capture must -// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is -// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including -// a mid-batch failure or early return — because selection restoration is part of the -// non-destructive invariant. Snapshot on construct (the currently-selected item set), -// restore on destruct (deselect everything, then re-select exactly the snapshot). -class ItemSelectionGuard -{ -public: - ItemSelectionGuard() - { - const int n = CountSelectedMediaItems(nullptr); - for (int i = 0; i < n; ++i) - if (MediaItem* it = GetSelectedMediaItem(nullptr, i)) - selected_.push_back(it); - } - - ~ItemSelectionGuard() - { - // Deselect every item in the project, then re-select the snapshot — restoring the - // exact original set regardless of what the batch selected in between. Iterate ALL - // items (not just the currently-selected) so any transient selection is cleared. - const int total = CountMediaItems(nullptr); - for (int i = 0; i < total; ++i) - if (MediaItem* it = GetMediaItem(nullptr, i)) - SetMediaItemSelected(it, false); - for (MediaItem* it : selected_) - SetMediaItemSelected(it, true); - UpdateArrange(); // reflect the restored selection in the arrange view - } - - ItemSelectionGuard(const ItemSelectionGuard&) = delete; - ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete; - -private: - std::vector selected_; -}; - -// Selects exactly `item` (deselect-all then select-one) so the offline render's -// selected-items bit (&32) captures a single item. Used inside the batch loop under the -// ItemSelectionGuard, which restores the user's original selection afterward. -static void selectOnlyItem(MediaItem* item) -{ - const int total = CountMediaItems(nullptr); - for (int i = 0; i < total; ++i) - if (MediaItem* it = GetMediaItem(nullptr, i)) - SetMediaItemSelected(it, it == item); -} - -// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the -// selection (RAII restore on every path), then for each selected item transiently selects -// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the -// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for -// the whole batch). Reports a mixed-result summary (explicit-action response — allowed). -static void RunBatchCaptureItems() -{ - // Read the selected items up front (pointers stay valid — batch mutates only selection - // flags, never adds/removes items). Also capture each item's exact bounds and owning - // track NOW, while the full selection is live, before any transient re-selection. - struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; - std::vector itemUnits; - { - const int n = CountSelectedMediaItems(nullptr); - for (int i = 0; i < n; ++i) - { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (!it) continue; - MediaTrack* tr = GetMediaItem_Track(it); - if (!tr) continue; - const double pos = GetMediaItemInfo_Value(it, "D_POSITION"); - const double len = GetMediaItemInfo_Value(it, "D_LENGTH"); - itemUnits.push_back({it, tr, pos, pos + len}); - } - } - if (itemUnits.empty()) - { - ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n"); - return; - } - - // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ - // inverted item ranges (a zero-length item) are dropped here so no stray render runs. - std::vector ranges; - ranges.reserve(itemUnits.size()); - for (const ItemUnit& u : itemUnits) - ranges.push_back({u.start, u.end}); - const std::vector plan = reasampler::planCaptureUnits(ranges); - - reasampler::BatchOutcome outcome; - bool anyAdded = false; - { - // Restore the user's ORIGINAL item selection on every exit path (incl. early - // return / mid-batch failure) — non-destructive invariant. - ItemSelectionGuard selGuard; - - // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only - // for those whose range survived planning (same drop rule), matching by ordinal. - std::size_t planIdx = 0; - for (const ItemUnit& u : itemUnits) - { - if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep - const reasampler::CaptureUnit& unit = plan[planIdx++]; - - // Transiently select ONLY this item so the item-scope render captures exactly it. - selectOnlyItem(u.item); - - ResolvedSource src; - src.startSeconds = unit.startSeconds; - src.endSeconds = unit.endSeconds; - src.sourceTracks.push_back(u.track); - if (std::string g = reasampler::guidString(u.track); !g.empty()) - src.trackGuids.push_back(std::move(g)); - - const std::string baseName = "item-" + std::to_string(unit.ordinal); - reasampler::CaptureResult res = captureAndIndexOne( - reasampler::CaptureScope::Item, src, baseName, - unit.startSeconds, unit.endSeconds); - - const bool ok = (res.status == reasampler::CaptureStatus::Ok); - outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); - if (ok) anyAdded = true; - } - } // selGuard restores the original selection here, on every path - - // Persist ONCE for the whole batch (one ext-state write) — only if something landed. - // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, - // so a single increment past the last-seen value is enough to trigger one instance reload. - if (anyAdded) { - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - } - - ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); -} - -// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving -// track order then area order — the batch analog of resolveRazorRange, which unions them. -// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser. -static std::vector> collectRazorAreas() -{ - std::vector> areas; - const int n = CountTracks(nullptr); - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetTrack(nullptr, i); - if (!tr) continue; - std::vector buf(8192, '\0'); - if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) - continue; - for (const reasampler::RazorRange& r : - reasampler::parseRazorEdits(std::string(buf.data()))) - areas.push_back({tr, r}); - } - return areas; -} - -// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must -// transiently select exactly the area's owning track per render (track scope's &128 bit -// renders whatever TRACKS are selected); the user's original track selection is restored -// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard. -class TrackSelectionGuard -{ -public: - TrackSelectionGuard() - { - const int n = CountSelectedTracks(nullptr); - for (int i = 0; i < n; ++i) - if (MediaTrack* tr = GetSelectedTrack(nullptr, i)) - selected_.push_back(tr); - } - - ~TrackSelectionGuard() - { - // Deselect every track, then re-select the snapshot — the exact original set. - const int total = CountTracks(nullptr); - for (int i = 0; i < total; ++i) - if (MediaTrack* tr = GetTrack(nullptr, i)) - SetTrackSelected(tr, false); - for (MediaTrack* tr : selected_) - SetTrackSelected(tr, true); - } - - TrackSelectionGuard(const TrackSelectionGuard&) = delete; - TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete; - -private: - std::vector selected_; -}; - -// Batch razor capture: one bank sample per razor AREA, track scope over that area's own -// range (the area's owning track is the source track). Track scope renders the selected -// TRACKS via master (&128), so each unit transiently selects ONLY its owning track -// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original -// track selection on every path. The razor selection itself is read-only and left intact. -// Persists ONCE at the end. Reports a mixed-result summary. -static void RunBatchCaptureRazor() -{ - const std::vector> areas = - collectRazorAreas(); - if (areas.empty()) - { - ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); - return; - } - - std::vector ranges; - ranges.reserve(areas.size()); - for (const auto& a : areas) - ranges.push_back({a.second.startSeconds, a.second.endSeconds}); - const std::vector plan = reasampler::planCaptureUnits(ranges); - - reasampler::BatchOutcome outcome; - bool anyAdded = false; - { - // Restore the user's ORIGINAL track selection on every exit path. - TrackSelectionGuard selGuard; - - std::size_t planIdx = 0; - for (const auto& a : areas) - { - if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep - const reasampler::CaptureUnit& unit = plan[planIdx++]; - MediaTrack* tr = a.first; - - // Transiently select ONLY this track so the track-scope render (&128) captures - // exactly it via master (over the custom time bounds we set per unit). - SetOnlyTrackSelected(tr); - - ResolvedSource src; - src.startSeconds = unit.startSeconds; - src.endSeconds = unit.endSeconds; - src.sourceTracks.push_back(tr); - if (std::string g = reasampler::guidString(tr); !g.empty()) - src.trackGuids.push_back(std::move(g)); - - const std::string baseName = "razor-" + std::to_string(unit.ordinal); - reasampler::CaptureResult res = captureAndIndexOne( - reasampler::CaptureScope::Track, src, baseName, - unit.startSeconds, unit.endSeconds); - - const bool ok = (res.status == reasampler::CaptureStatus::Ok); - outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); - if (ok) anyAdded = true; - } - } // selGuard restores the original track selection here, on every path - - // S9: one coalesced bump for the whole razor batch (see the item-batch note above). - if (anyAdded) { - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - } - - ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); -} - -// --- M10: re-capture from source -------------------------------------------- -// -// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT -// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and -// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the -// load-bearing capture-never-places line, structurally visible: this function has no -// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore -// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places -// manually if they want the new version on the timeline. -// -// Failure modes are handled explicitly and reported to the user (a direct response -// to an explicit action is allowed by the console policy): -// * the selected sample has no provenance (not a resample) -> reported, no-op. -// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. -// * the recorded source track(s) no longer exist -> reported, no-op. -// * the render itself fails to satisfy the recorded request -> reported, no-op. -// On success, if the source FX chain drifted since capture (recorded vs current -// identity differ) the user is told — the re-capture still reflects the source AS IT -// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). -static void RunRecaptureFromSource() -{ - const std::vector selected = reasampler::bankPanelSelectedSampleIds(); - if (selected.empty()) - { - ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n"); - return; - } - if (selected.size() > 1) - { - ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n"); - return; - } - const std::string sampleId = selected.front(); - - // Resolve the sample from the bank it lives in (the focused region's displayed bank). - const std::string srcBankId = reasampler::bankPanelSelectedSourceBankId(); - const reasampler::Bank* bank = g_session.book().bank(srcBankId); - const reasampler::Sample* orig = bank ? bank->index.query(sampleId) : nullptr; - if (!orig) - { - ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n"); - return; - } - if (!orig->provenance) - { - ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance " - "(it was not resampled from a bank sample).\n"); - return; - } - - // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt - // string fails gracefully — never a partial re-capture. - const std::string recordedParentId = orig->provenance->parentSampleId; - const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; - const std::optional recipe = - reasampler::parseFingerprint(recordedFingerprint); - if (!recipe) - { - ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable " - "(recorded by an older/incompatible build); cannot re-capture.\n"); - return; - } - - // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a - // hard failure — we will not silently re-capture a different source. - std::vector sourceTracks; - for (const std::string& g : recipe->trackGuids) - { - MediaTrack* tr = reasampler::trackByGuid(g); - if (!tr) - { - ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer " - "exists in this project; cannot re-capture from source.\n"); - return; - } - sourceTracks.push_back(tr); - } - if (sourceTracks.empty()) - { - // The recipe recorded no source tracks (e.g. an item-scope capture whose source - // tracks were not track-scoped). Without a resolvable source we cannot re-run. - ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " - "sample; cannot re-capture from source.\n"); - return; - } - - const reasampler::CaptureScope scope = - recipe->scope == reasampler::ProvenanceScope::Item - ? reasampler::CaptureScope::Item - : reasampler::CaptureScope::Track; - - // Rebuild the capture request verbatim from the recorded recipe — the SAME request, - // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, - // channels, bit depth all match the original so an unchanged source produces a - // byte-identical file (bit-identical-repeats invariant, consumed as a feature). - reasampler::CaptureRequest req; - req.sourceMode = static_cast(recipe->sourceMode); - req.startSeconds = recipe->startSeconds; - req.endSeconds = recipe->endSeconds; - req.wetDry = 1.0; - req.tailMode = static_cast(recipe->tailMode); - req.tailMs = recipe->tailMs; - req.sampleRate = recipe->sampleRate; - req.channelCount = recipe->channelCount; - req.bitDepth = reasampler::WavBitDepth::Float32; - req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; - req.trackGuids = recipe->trackGuids; - - // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to - // compare against the recorded identity for drift reporting. Mirror the same - // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; - // track scope reads the track FX chain via TrackFX_*. - std::string currentIdentity; - if (scope == reasampler::CaptureScope::Item) { - const int n = CountSelectedMediaItems(nullptr); - std::vector items; - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (it) items.push_back(it); - } - currentIdentity = reasampler::fxChainIdentityForItems(items); - } else { - std::vector perTrackNow; - perTrackNow.reserve(sourceTracks.size()); - for (MediaTrack* tr : sourceTracks) - perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr)); - currentIdentity = reasampler::combineChainIdentities(perTrackNow); - } - const bool drifted = (currentIdentity != recipe->fxChainIdentity); - - // Render (bank-only; renderOffline never touches the timeline). - reasampler::CaptureResult res = renderOffline(scope, sourceTracks, req); - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str()); - return; - } - - // Update the Sample IN PLACE: keep its identity (id) and its provenance thread - // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but - // adopt the regenerated file's path / hash / length / rate / timestamp. The - // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a - // subsequent re-capture measures drift from this point, not the original. - reasampler::CaptureRecipe refreshed = *recipe; - refreshed.fxChainIdentity = currentIdentity; - - reasampler::Sample updated = *orig; // copy: preserves id, displayName, tier, key - updated.relativePath = res.sample.relativePath; - updated.contentHash = res.sample.contentHash; - updated.sourceMode = res.sample.sourceMode; - updated.sourceRange = res.sample.sourceRange; - updated.channelCount = res.sample.channelCount; - updated.sampleRate = res.sample.sampleRate; - updated.lengthSeconds = res.sample.lengthSeconds; - updated.captureTempo = res.sample.captureTempo; - updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp - updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter - updated.trackGuids = res.sample.trackGuids; - updated.createdTimestamp = res.sample.createdTimestamp; - // NOTE: levels, clipped, and lengthBeats are carried from the original (via the - // *orig copy above) because the offline backend does not populate them today - // (res.sample leaves them at defaults). If a later milestone populates these - // fields at capture time, refresh them here from res.sample instead. - reasampler::Provenance prov; - prov.parentSampleId = recordedParentId; - prov.fxChainSnapshot = reasampler::buildFingerprint(refreshed); - updated.provenance = prov; - - // Single batched undo point around the in-place bank mutation (mirrors the bank - // action family's R-B pattern). The mutation is index-only ext-state; the render - // wrote a new file but placed nothing on the timeline. - Undo_BeginBlock2(nullptr); - const bool changed = g_session.book().updateSampleInPlace(sampleId, updated); - if (changed) - { - // Record the regenerated file in the owned manifest (a new file the tool wrote); - // the superseded old file becomes an orphan reclaimed by Phase R prune. - g_session.owned().add(updated.relativePath); - // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the - // hands-free refresh exists for (an instance referencing this id keeps playing the - // OLD audio until it reloads). Bump inside the undo block so undo rolls back the - // generation with the rest of the blob. - g_session.bumpBankGeneration(); - const bool persisted = g_session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty - Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", - persisted ? UNDO_STATE_MISCCFG : 0); - } - else - { - Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point - } - - reasampler::bankPanelRefresh(); // reflect the regenerated file in the docked grid - - if (drifted) - ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the " - "original capture -- the sample was regenerated from the source's " - "current state.\n"); -} - -// STARTS the REALTIME track capture and returns immediately — the record runs across -// timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the -// selected tracks + the range (razor-else-time, the same orthogonal range logic as the -// offline scopes) and starts recording each selected track's OWN output into a hidden -// temp track via RealtimeRecordBackend::begin (a send FROM each source track INTO the -// temp — see capture_realtime.cpp §TAP); OnTimer drives it to completion, then adds the -// Sample and persists. TRACK scope only this increment (item realtime is deferred). -// Dialog-free. Non-bit-identical by nature (it is realtime) — offline stays the -// deterministic default. FxBypassGuard is NOT used here — the track-output tap is -// PRE-parent by construction (§TAP), so there is no live chain to neutralize. The -// load-bearing principle holds structurally — this writes a file + a bank entry ONLY; -// the temp track is a transient sink removed by the backend, nothing lands in arrange. -// -// A SECOND realtime capture requested while one is in progress is REJECTED — the -// first keeps running (we own the transport for its window; starting a second would -// collide on the transport and the temp-track/arm snapshot). -static void RunCaptureRealtimeTrack() -{ - if (g_rtCapture) - { - ShowConsoleMsg("ReaSampler realtime capture: a capture is already in " - "progress -- let it finish (or stop the transport) first.\n"); - return; - } - - // Resolve the selected tracks + range exactly as the offline Track scope does. - // No track selected -> refuse (same no-op as offline track scope). - ResolvedSource src; - std::string why; - if (!ResolveScopeSource(reasampler::CaptureScope::Track, src, why)) - { - ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str()); - return; - } - - // The tail mode is the SAME panel setting the offline capture actions read (the - // docked bank panel's toggle). Realtime honors it via a parallel path: the backend - // records a generous window past the range end, then trims by PCM decay-scan (T2 / - // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None - // keeps realtime exact-bounds / byte-identical to today. - const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope - req.startSeconds = src.startSeconds; // exact bounds — no rounding - req.endSeconds = src.endSeconds; - req.wetDry = 1.0; // fully wet (post-fader tap) - req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle - req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto - req.sampleRate = 0; // follow project rate - req.channelCount = 2; - req.bitDepth = reasampler::WavBitDepth::Float32; - req.baseName = "realtime"; - req.trackGuids = src.trackGuids; // provenance on the Sample - - reasampler::CaptureResult failure; - reasampler::RealtimeCaptureHandle st = - g_rtBackend.begin(req, src.sourceTracks, failure); - if (!st) - { - // begin() validated/failed and already restored anything it touched. - ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str()); - return; - } - - // Started. Store the in-flight state + its project; OnTimer drives it to - // completion across ticks (UI stays responsive). - g_rtCaptureProject = EnumProjects(-1, nullptr, 0); - g_rtCapture = std::move(st); -} - -// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates -// via abort() — stop the transport + restore ALL snapshotted state (non-destructive), -// committing whatever audio was already captured (best effort) so a cancel near the end -// still keeps the take. Runs only against the record's OWN project (abort() self-guards -// the closed-project case, review §1). No-op with a note when nothing is in flight. -static void RunCancelRealtime() -{ - if (!g_rtCapture) - { - ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); - return; - } - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - if (r.status == reasampler::RealtimeTickStatus::Done) - CommitRealtimeResult(r.result); // Ok: keep what was captured up to the cancel - else - ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " + - r.result.message + "\n").c_str()); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; -} - -// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor -// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match -// variant (never silent — it fires only from the distinct "conform" action). This -// is the INTENDED placement path: it adds items to the arrange on purpose -// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action. -static void RunInsertSelected(bool conform) -{ - reasampler::InsertRequest req; - // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the - // user's currently-selected track(s) at the edit cursor. - req.options.conform = - conform ? reasampler::TempoConform::Ratio1x : reasampler::TempoConform::None; - // preservePitch stays true: a tempo conform matches tempo without varispeeding - // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.) - - reasampler::InsertResult res = reasampler::runInsert(&g_session, req); - - switch (res.status) - { - case reasampler::InsertStatus::Ok: - break; // success — no console chatter - case reasampler::InsertStatus::NoSelection: - // "select a track first" is printed by runInsert when no track is - // selected; this branch covers the no-panel-selection case. - ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n"); - break; - case reasampler::InsertStatus::NoProject: - ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); - break; - case reasampler::InsertStatus::NothingResolved: - ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); - break; - } -} - // REAPER calls this for EVERY action fired anywhere; claim only our own id, // return false otherwise so REAPER keeps looking. static bool OnHookCommand(int command, int /*flag*/) @@ -1520,22 +283,22 @@ static bool OnHookCommand(int command, int /*flag*/) for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i) if (command == g_captureCmdIds[i]) { - RunCapture(reasampler::captureActionTable()[i]); + capture::RunCapture(g_session, capture::captureActionTable()[i]); return true; } if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } - if (command == g_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; } - if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } - if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } - if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; } - if (command == g_cmdCaptureBatchRazor) { RunBatchCaptureRazor(); return true; } - if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } - if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } - if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; } + if (command == g_cmdCaptureItemAssign) { capture::RunCaptureItemAssign(g_session); return true; } + if (command == g_cmdInsertSelected) { capture::RunInsertSelected(g_session, false); return true; } + if (command == g_cmdInsertSelectedConform) { capture::RunInsertSelected(g_session, true); return true; } + if (command == g_cmdCaptureBatchItems) { capture::RunBatchCaptureItems(g_session); return true; } + if (command == g_cmdCaptureBatchRazor) { capture::RunBatchCaptureRazor(g_session); return true; } + if (command == g_cmdCaptureTrackRealtime) { capture::RunCaptureRealtimeTrack(g_session); return true; } + if (command == g_cmdCancelRealtime) { capture::RunCancelRealtime(g_session); return true; } + if (command == g_cmdRecaptureFromSource) { capture::RunRecaptureFromSource(g_session); return true; } if (command == g_cmdShowVersion) { // On-demand version readout — the ONLY version output on any path. - ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str()); + ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); return true; } // Design View action family (D4). Claims only its own ids; returns false for the @@ -1610,13 +373,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // still live — finalize-or-abort + restore so we never leave a temp track, // an armed track, or an altered transport/cursor in the user's project on // unload. Commit whatever was captured (best effort) before tearing down. - if (g_rtCapture) - { - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - CommitRealtimeResult(r.result); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; - } + capture::AbortRealtimeCaptureForUnload(g_session); g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); @@ -1655,13 +412,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // '-'-prefixed strings (per the contract). The command id is re-composed from // the same suffix + channel prefix used at register — identical string. { - const auto& table = reasampler::captureActionTable(); + const auto& table = capture::captureActionTable(); for (std::size_t i = 0; i < table.size(); ++i) { if (i < g_captureAccels.size()) g_rec->Register("-gaccel", (void*)&g_captureAccels[i]); - const std::string id = - reasampler::channelCommandId(table[i].commandSuffix); + const std::string id = channelCommandId(table[i].commandSuffix); g_rec->Register("-command_id", (void*)id.c_str()); } } @@ -1670,7 +426,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // per channel so a beta clears beta-qualified retired ids, stable clears its own. for (const char* suffix : kRetiredCaptureCmdSuffixes) { - const std::string id = reasampler::channelCommandId(suffix); + const std::string id = channelCommandId(suffix); g_rec->Register("-command_id", (void*)id.c_str()); } } @@ -1698,7 +454,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // g_captureAccels must be sized BEFORE the loop and never reallocated after — // REAPER holds a pointer to each element until we mirror-unregister it. { - const auto& table = reasampler::captureActionTable(); + const auto& table = capture::captureActionTable(); g_captureCmdIds.assign(table.size(), 0); g_captureAccels.assign(table.size(), gaccel_register_t{}); g_captureDescs.assign(table.size(), std::string{}); @@ -1712,8 +468,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_captureCmdIds[i] = cmd; if (cmd) { - g_captureDescs[i] = - reasampler::channelActionName(table[i].descriptionPhrase); + g_captureDescs[i] = channelActionName(table[i].descriptionPhrase); g_captureAccels[i].accel.cmd = cmd; g_captureAccels[i].desc = g_captureDescs[i].c_str(); rec->Register("gaccel", (void*)&g_captureAccels[i]); @@ -1732,7 +487,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel); if (g_cmdToggleBankPanel) { - g_descToggleBankPanel = reasampler::channelActionName("toggle bank panel"); + g_descToggleBankPanel = channelActionName("toggle bank panel"); g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; g_accelToggleBankPanel.desc = g_descToggleBankPanel.c_str(); rec->Register("gaccel", (void*)&g_accelToggleBankPanel); @@ -1744,13 +499,13 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // writes an assignment request so the active instance plays the new sample. Channel- // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every // capture action. Registered in the capture family (main.cpp) because it leans on the - // capture render machinery here; the other two ingest surfaces live in the ingest family + // capture render machinery; the other two ingest surfaces live in the ingest family // (Media-Explorer import) and the panel drop callback. g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN"); g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign); if (g_cmdCaptureItemAssign) { - g_descCaptureItemAssign = reasampler::channelActionName( + g_descCaptureItemAssign = channelActionName( "capture selected item into bank + assign to active instance"); g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign; g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str(); @@ -1764,8 +519,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected); if (g_cmdInsertSelected) { - g_descInsertSelected = - reasampler::channelActionName("insert selected sample at edit cursor"); + g_descInsertSelected = channelActionName("insert selected sample at edit cursor"); g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; g_accelInsertSelected.desc = g_descInsertSelected.c_str(); rec->Register("gaccel", (void*)&g_accelInsertSelected); @@ -1775,7 +529,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform); if (g_cmdInsertSelectedConform) { - g_descInsertSelectedConform = reasampler::channelActionName( + g_descInsertSelectedConform = channelActionName( "insert selected sample at edit cursor (conform to tempo)"); g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; g_accelInsertSelectedConform.desc = g_descInsertSelectedConform.c_str(); @@ -1791,7 +545,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdCaptureBatchItems) { g_descCaptureBatchItems = - reasampler::channelActionName("batch capture selected items (one per item)"); + channelActionName("batch capture selected items (one per item)"); g_accelCaptureBatchItems.accel.cmd = g_cmdCaptureBatchItems; g_accelCaptureBatchItems.desc = g_descCaptureBatchItems.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureBatchItems); @@ -1802,7 +556,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdCaptureBatchRazor) { g_descCaptureBatchRazor = - reasampler::channelActionName("batch capture razor areas (one per area)"); + channelActionName("batch capture razor areas (one per area)"); g_accelCaptureBatchRazor.accel.cmd = g_cmdCaptureBatchRazor; g_accelCaptureBatchRazor.desc = g_descCaptureBatchRazor.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureBatchRazor); @@ -1817,7 +571,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdCaptureTrackRealtime) { g_descCaptureTrackRealtime = - reasampler::channelActionName("capture selected track (realtime)"); + channelActionName("capture selected track (realtime)"); g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; g_accelCaptureTrackRealtime.desc = g_descCaptureTrackRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); @@ -1829,7 +583,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime); if (g_cmdCancelRealtime) { - g_descCancelRealtime = reasampler::channelActionName("cancel realtime capture"); + g_descCancelRealtime = channelActionName("cancel realtime capture"); g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime; g_accelCancelRealtime.desc = g_descCancelRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCancelRealtime); @@ -1843,7 +597,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource); if (g_cmdRecaptureFromSource) { - g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source"); + g_descRecaptureFromSource = channelActionName("re-capture from source"); g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource; g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str(); rec->Register("gaccel", (void*)&g_accelRecaptureFromSource); @@ -1857,7 +611,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion); if (g_cmdShowVersion) { - g_descShowVersion = reasampler::channelActionName("show version"); + g_descShowVersion = channelActionName("show version"); g_accelShowVersion.accel.cmd = g_cmdShowVersion; g_accelShowVersion.desc = g_descShowVersion.c_str(); rec->Register("gaccel", (void*)&g_accelShowVersion); diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 175f40a..705b28f 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -2,119 +2,13 @@ #include #include -#include -#include -#include // std::memcmp #include -#include namespace reasampler::capture { -std::string hashBytes(const std::uint8_t* data, std::size_t len) { - // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. - // Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/). - constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL; - constexpr std::uint64_t kPrime = 1099511628211ULL; - std::uint64_t h = kOffsetBasis; - for (std::size_t i = 0; i < len; ++i) { - h ^= static_cast(data[i]); - h *= kPrime; - } - // Format as 16-digit lowercase hex (zero-padded) for a fixed-length string. - char buf[17]; - std::snprintf(buf, sizeof(buf), "%016llx", - static_cast(h)); - return std::string(buf); -} - -std::string hashWavContent(const std::vector& bytes) { - // Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body - // through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57). - // Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped. - // If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back - // to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash. - // - // The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates - // FNV state instead of recording geometry — no second parser, same logic. - - // FNV-1a 64-bit constants (same as hashBytes). - constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL; - constexpr std::uint64_t kPrime = 1099511628211ULL; - - // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. - auto tagEq = [&](std::size_t off, const char* tag) -> bool { - return off + 4 <= bytes.size() && - std::memcmp(bytes.data() + off, tag, 4) == 0; - }; - auto readU32LE = [&](std::size_t off) -> std::uint32_t { - return static_cast(bytes[off]) | - (static_cast(bytes[off + 1]) << 8) | - (static_cast(bytes[off + 2]) << 16) | - (static_cast(bytes[off + 3]) << 24); - }; - - bool isWav = bytes.size() >= 12 && - tagEq(0, "RIFF") && - tagEq(8, "WAVE"); - - if (isWav) { - // Accumulate FNV-1a starting with the domain-separation tag byte 'W'. - std::uint64_t h = kOffsetBasis; - auto feedByte = [&](std::uint8_t b) { - h ^= static_cast(b); - h *= kPrime; - }; - - bool haveFmt = false; - bool haveData = false; - - // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a - // whole-file hash of different bytes that happen to be the same length. - feedByte(static_cast('W')); - - std::size_t pos = 12; - while (pos + 8 <= bytes.size()) { - const std::size_t bodyOffset = pos + 8; - const std::uint32_t bodySize = readU32LE(pos + 4); - - if (tagEq(pos, "fmt ")) { - // Feed the entire fmt body (all fields, including format tag, channels, - // sample rate, bits-per-sample — everything that defines the audio format). - if (bodyOffset + bodySize <= bytes.size()) { - for (std::uint32_t i = 0; i < bodySize; ++i) - feedByte(bytes[bodyOffset + i]); - haveFmt = true; - } - } else if (tagEq(pos, "data")) { - // Feed the entire PCM payload. - if (bodyOffset + bodySize <= bytes.size()) { - for (std::uint32_t i = 0; i < bodySize; ++i) - feedByte(bytes[bodyOffset + i]); - haveData = true; - } - } - // All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped. - - // Advance past this chunk's body, honoring RIFF even-byte padding. - std::size_t advance = bodySize; - if (advance & 1u) ++advance; // RIFF pad byte - if (advance > bytes.size() - bodyOffset) break; // overrun guard - pos = bodyOffset + advance; - } - - if (haveFmt && haveData) { - char buf[17]; - std::snprintf(buf, sizeof(buf), "%016llx", - static_cast(h)); - return std::string(buf); - } - // Falls through to whole-file fallback if chunks were missing/malformed. - } - - // Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old - // per-call hashBytes). No prefix tag: identical to hashBytes(data, size). - return hashBytes(bytes.data(), bytes.size()); -} +// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec +// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the +// layout parse so hashing and decoding cannot desynchronize. std::string normalizeSlashes(const std::string& path) { std::string out = path; diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 1cb9f6c..bd188e3 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -34,37 +34,9 @@ struct BankPaths { std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) }; -// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` -// and returns it as a 16-character lowercase hex string. Designed to fill -// Sample::contentHash so the confirm-on-last-reference guardrail -// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this -// file" from "another bank holds the same file." An empty buffer returns the bare -// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty -// files would share, but real WAV files are never empty). -std::string hashBytes(const std::uint8_t* data, std::size_t len); - -// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float -// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all -// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). -// -// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a -// `bext` chunk containing the origination date/time) even when the format config blob -// requests no BWF metadata. Two renders of identical audio therefore differ in those -// bytes, making whole-file hashes diverge and preventing dedup collapse. -// -// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before -// the fmt/data bytes are fed in, so a content hash can never equal a whole-file -// hashBytes result for a different file of the same size. -// -// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a -// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — -// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an -// unrecognized or malformed file still gets a non-empty hash rather than silently -// skipping dedup. -// -// Called by both capture commit paths (offline and realtime) in place of the raw -// hashBytes call. -std::string hashWavContent(const std::vector& bytes); +// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent) +// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte +// format — so this module holds path arithmetic only, with no RIFF chunk knowledge. // Normalizes a path to forward slashes and strips any trailing slash. Empty in // -> empty out. Pure string transform (does not consult the filesystem). diff --git a/src/core/capture/realtime_record.cpp b/src/core/capture/capture_realtime.cpp similarity index 95% rename from src/core/capture/realtime_record.cpp rename to src/core/capture/capture_realtime.cpp index 9a754c4..2ff05e4 100644 --- a/src/core/capture/realtime_record.cpp +++ b/src/core/capture/capture_realtime.cpp @@ -1,7 +1,8 @@ -// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header. -// NO REAPER types; unit-tested by tests/test_realtime_record.cpp. +// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See +// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp. +// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.) -#include "core/capture/realtime_record.h" +#include "core/capture/capture_realtime.h" namespace reasampler::capture { diff --git a/src/core/capture/realtime_record.h b/src/core/capture/capture_realtime.h similarity index 96% rename from src/core/capture/realtime_record.h rename to src/core/capture/capture_realtime.h index 76e551a..b1da9c9 100644 --- a/src/core/capture/realtime_record.h +++ b/src/core/capture/capture_realtime.h @@ -1,9 +1,12 @@ #pragma once -// realtime_record — the REAPER-free logic behind the realtime-record backend (M8). +// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8). +// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module +// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp / +// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.) // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The realtime backend (capture.cpp) -// drives the transport, the temp track, the send routing, and the file move — +// vendor/ includes. Standard library only. The realtime shell drives the +// transport, the temp track, the send routing, and the file move — // all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong // pieces are split out here and unit-tested outside the DAW: // diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp new file mode 100644 index 0000000..99d76fb --- /dev/null +++ b/src/core/capture/wav_codec.cpp @@ -0,0 +1,333 @@ +// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor. +// +// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the +// content hash both walk with it, so their view of the container cannot drift. + +#include "core/capture/wav_codec.h" + +#include // std::snprintf (hash hex render) +#include // std::memcpy, std::memcmp + +namespace reasampler::capture { + +namespace { + +// Little-endian readers. Bounds are checked by the caller before each read; these +// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. +std::uint16_t readU16LE(const std::vector& b, std::size_t off) { + return static_cast(b[off] | (b[off + 1] << 8)); +} +std::uint32_t readU32LE(const std::vector& b, std::size_t off) { + return static_cast(b[off]) | + (static_cast(b[off + 1]) << 8) | + (static_cast(b[off + 2]) << 16) | + (static_cast(b[off + 3]) << 24); +} + +bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { + return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; +} + +// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION). +constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; +constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; + +// FNV-1a 64-bit constants (http://www.isthe.com/chongo/tech/comp/fnv/). +constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL; +constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + +std::string fnvHex(std::uint64_t h) { + // 16-digit lowercase hex (zero-padded) for a fixed-length string. + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", static_cast(h)); + return std::string(buf); +} + +// --- The ONE RIFF chunk traversal -------------------------------------------- +// +// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at +// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`. +// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk +// whose declared size lies past the end is still REPORTED (callers decide how to +// treat it) but its body must not be read. +struct WavChunkView { + std::size_t headerOffset = 0; + std::size_t bodyOffset = 0; + std::uint32_t bodySize = 0; + bool bodyInBounds = false; +}; + +// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call +// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte +// padding. Returns false when no further chunk header fits. If the padded advance +// would overrun the buffer, the chunk is still reported (return true) and `pos` is +// parked past the end so the NEXT call returns false — exactly the process-then- +// break shape the pre-consolidation walkers shared. +bool nextWavChunk(const std::vector& bytes, std::size_t& pos, + WavChunkView& out) { + if (pos + 8 > bytes.size()) return false; + + out.headerOffset = pos; + out.bodyOffset = pos + 8; + out.bodySize = readU32LE(bytes, pos + 4); + out.bodyInBounds = (out.bodyOffset + out.bodySize <= bytes.size()); + + std::size_t advance = out.bodySize; + if (advance & 1u) ++advance; // RIFF pad byte + if (advance > bytes.size() - out.bodyOffset) { + pos = bytes.size(); // overrun -> this is the last reported chunk + } else { + pos = out.bodyOffset + advance; + } + return true; +} + +bool isRiffWave(const std::vector& bytes) { + // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. + return bytes.size() >= 12 && tagEquals(bytes, 0, "RIFF") && + tagEquals(bytes, 8, "WAVE"); +} + +} // namespace + +WavLayout parseWavLayout(const std::vector& bytes) { + WavLayout out; + + if (!isRiffWave(bytes)) return out; + + bool haveFmt = false; + std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0; + std::uint32_t sampleRate = 0; + std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible + + // Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A + // malformed/truncated file is "invalid", never an OOB read. + std::size_t pos = 12; + WavChunkView c; + while (nextWavChunk(bytes, pos, c)) { + if (tagEquals(bytes, c.headerOffset, "fmt ")) { + // fmt body: at least 16 bytes (PCM/float common fields). + if (c.bodyOffset + 16 > bytes.size() || c.bodySize < 16) return out; + fmtTag = readU16LE(bytes, c.bodyOffset + 0); + channels = readU16LE(bytes, c.bodyOffset + 2); + sampleRate = readU32LE(bytes, c.bodyOffset + 4); + bitsPerSample = readU16LE(bytes, c.bodyOffset + 14); + // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading + // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM + // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to + // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in + // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). + if (fmtTag == kWaveFormatExtensible) { + if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) { + extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24); + } + } + haveFmt = true; + } else if (tagEquals(bytes, c.headerOffset, "data")) { + // The data chunk: PCM starts at bodyOffset, declared length bodySize. + // Reject if it runs past the buffer (truncated / lying header). + if (!c.bodyInBounds) return out; + if (!haveFmt) return out; // data before fmt — not a WAV we parse + + // Plain IEEE-float tag (0x0003): accept as-is. + // Extensible tag (0xFFFE): accept only when the SubFormat tag read from + // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag + // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT + // float and must be rejected to prevent mis-decoding as float. + const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || + (fmtTag == kWaveFormatExtensible && + extensibleSubFormatTag == kWaveFormatIeeeFloat); + if (!floatTag || bitsPerSample != 32 || channels == 0) return out; + + out.valid = true; + out.channelCount = channels; + out.sampleRate = sampleRate; + out.dataByteOffset = c.bodyOffset; + out.dataByteLength = c.bodySize; + out.riffSizeFieldOffset = 4; + out.dataSizeFieldOffset = c.headerOffset + 4; // the `data` size field (LE uint32) + return out; + } + } + + return out; // no data chunk found -> invalid +} + +std::vector extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount) { + std::vector out; + if (!layout.valid) return out; + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t totalFrames = layout.frameCount(); + if (startFrame >= totalFrames) return out; + + // Clamp the requested span to the frames that actually exist. + const std::size_t avail = totalFrames - startFrame; + const std::size_t frames = (frameCount < avail) ? frameCount : avail; + if (frames == 0) return out; + + const std::size_t firstByte = + layout.dataByteOffset + startFrame * bytesPerFrame; + out.resize(frames * layout.channelCount); + // memcpy each float (LE on target hosts — see header's byte-order note). + for (std::size_t i = 0; i < out.size(); ++i) { + float f = 0.0f; + std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); + out[i] = f; + } + return out; +} + +WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { + WavTruncatePlan plan; + if (!layout.valid) return plan; + + const std::size_t totalFrames = layout.frameCount(); + if (keptFrames > totalFrames) return plan; // never grow + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t keptDataBytes = keptFrames * bytesPerFrame; + + plan.valid = true; + plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; + plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; + plan.newDataSize = static_cast(keptDataBytes); + plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; + // RIFF size counts everything after the 8-byte "RIFF"+size prefix. + plan.newRiffSize = static_cast(plan.newFileByteLength - 8); + return plan; +} + +void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { + bytes[off + 0] = static_cast(v & 0xFF); + bytes[off + 1] = static_cast((v >> 8) & 0xFF); + bytes[off + 2] = static_cast((v >> 16) & 0xFF); + bytes[off + 3] = static_cast((v >> 24) & 0xFF); +} + +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved) { + const std::size_t sampleCount = frameCount * static_cast(nch); + const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32 + + // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload. + // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount. + const std::uint32_t riffSize = + static_cast(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data + + std::vector out; + out.reserve(44u + dataBytesCount); + + auto putU16 = [&](std::uint16_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + }; + auto putU32 = [&](std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); + }; + auto putTag = [&](const char* t) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(t[i])); + }; + auto putF32 = [&](float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) out.push_back(tmp[i]); + }; + + // RIFF header + putTag("RIFF"); + putU32(riffSize); + putTag("WAVE"); + + // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003) + putTag("fmt "); + putU32(16u); // chunk body size + putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT + putU16(static_cast(nch)); + putU32(rate); + putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec + putU16(static_cast(nch * 4)); // blockAlign + putU16(32u); // bitsPerSample + + // data chunk + putTag("data"); + putU32(static_cast(dataBytesCount)); + for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) + putF32(static_cast(interleaved[i])); + + return out; +} + +std::string hashBytes(const std::uint8_t* data, std::size_t len) { + // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. + std::uint64_t h = kFnvOffsetBasis; + for (std::size_t i = 0; i < len; ++i) { + h ^= static_cast(data[i]); + h *= kFnvPrime; + } + return fnvHex(h); +} + +std::string hashWavContent(const std::vector& bytes) { + // Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt ` + // body and `data` body through FNV-1a, prefixed with the domain-separation tag + // byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED, + // etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and + // data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized + // file still gets a hash. + if (isRiffWave(bytes)) { + std::uint64_t h = kFnvOffsetBasis; + auto feedByte = [&](std::uint8_t b) { + h ^= static_cast(b); + h *= kFnvPrime; + }; + + bool haveFmt = false; + bool haveData = false; + + // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a + // whole-file hash of different bytes that happen to be the same length. + feedByte(static_cast('W')); + + std::size_t pos = 12; + WavChunkView c; + while (nextWavChunk(bytes, pos, c)) { + if (tagEquals(bytes, c.headerOffset, "fmt ")) { + // Feed the entire fmt body (all fields, including format tag, channels, + // sample rate, bits-per-sample — everything that defines the audio format). + if (c.bodyInBounds) { + for (std::uint32_t i = 0; i < c.bodySize; ++i) + feedByte(bytes[c.bodyOffset + i]); + haveFmt = true; + } + } else if (tagEquals(bytes, c.headerOffset, "data")) { + // Feed the entire PCM payload. + if (c.bodyInBounds) { + for (std::uint32_t i = 0; i < c.bodySize; ++i) + feedByte(bytes[c.bodyOffset + i]); + haveData = true; + } + } + // All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped. + } + + if (haveFmt && haveData) return fnvHex(h); + // Falls through to whole-file fallback if chunks were missing/malformed. + } + + // Fallback: not a parseable RIFF/WAVE — hash the whole file (identical to + // hashBytes(data, size); no prefix tag). + return hashBytes(bytes.data(), bytes.size()); +} + +} // namespace reasampler::capture diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h new file mode 100644 index 0000000..e85aa75 --- /dev/null +++ b/src/core/capture/wav_codec.h @@ -0,0 +1,172 @@ +#pragma once +// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: +// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 +// build + size-field patch + the WAV-aware content hash, in one tested module. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. +// +// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte +// padding, size fields) was minted at four sites: wav_trim's layout parse, +// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and +// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte +// handling) would desynchronize hashing from decoding — the dedup-by-hash and +// null-test invariants both sit on this. Now every walker/builder/patcher is here, +// on ONE chunk-traversal implementation. +// +// WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime +// backend records a generous tail window, then trims the trailing decay by +// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is +// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk +// size and the `data` sub-chunk size) must be patched to the kept byte count, or +// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking, +// format verification, and the size-field patch offsets — is exactly the fiddly, +// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER +// shell does only the file I/O: read the bytes, call the pure parse, run the decay +// scan, call the pure plan, patch + write the truncated bytes. +// +// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV +// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project +// record format, which the manual procedure sets to WAV/32-bit-float). The parser +// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt ` +// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE +// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything +// else (a different depth, a non-WAV, a compressed source) is reported invalid and +// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a +// file it does not understand. This is deliberately conservative. + +#include +#include +#include +#include + +#include "core/audio/peaks.h" // AudioSample (float) + +namespace reasampler::capture { + +using audio::AudioSample; + +// --- Layout parse ------------------------------------------------------------ + +// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the +// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field +// is meaningful only when valid. +struct WavLayout { + bool valid = false; + + std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) + std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) + + // The `data` chunk: byte offset of its first PCM byte within the file, and its + // declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). + std::size_t dataByteOffset = 0; + std::size_t dataByteLength = 0; + + // Byte offset of the two little-endian uint32 size fields the truncate patch + // rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk + // size (the 4 bytes immediately before dataByteOffset). + std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file + std::size_t dataSizeFieldOffset = 0; + + std::size_t frameCount() const { + const std::size_t bytesPerFrame = static_cast(channelCount) * 4u; + return bytesPerFrame ? dataByteLength / bytesPerFrame : 0; + } +}; + +// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything +// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, +// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only +// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). +WavLayout parseWavLayout(const std::vector& bytes); + +// Copies `frameCount` interleaved float frames starting at `startFrame` out of the +// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). +// Clamps to the frames the buffer actually holds — never reads past `data`. Returns +// empty for an invalid layout or an out-of-range start. The floats are read +// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would +// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux +// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. +std::vector extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount); + +// --- Truncate plan + size-field patch --------------------------------------- + +// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte +// length and the two size-field values to patch. `valid` is false if the layout is +// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller +// clamps beforehand; this guards it too). +struct WavTruncatePlan { + bool valid = false; + + std::size_t newFileByteLength = 0; // truncate the file to exactly this length + std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32) + std::uint32_t newDataSize = 0; // kept PCM byte length + std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32) + std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes + // the 8-byte "RIFF"+size prefix) +}; + +// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. +// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + +// total. The shell applies it: patch the two size fields in the byte buffer +// (patchU32LE), then truncate the file to newFileByteLength. +WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); + +// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size +// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() +// (the plan's offsets came from a valid parse of the same buffer). +void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v); + +// --- Float32 WAV build ------------------------------------------------------- + +// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved +// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, +// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, +// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is +// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT +// ASSUMPTION above); the reduction is intentional. The output round-trips through +// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical +// source through REAPER's PCM_source, then writes the bank copy with this. +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved); + +// --- Content identity (dedup hashes) ----------------------------------------- + +// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` +// and returns it as a 16-character lowercase hex string. Designed to fill +// Sample::contentHash so the confirm-on-last-reference guardrail +// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this +// file" from "another bank holds the same file." An empty buffer returns the bare +// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty +// files would share, but real WAV files are never empty). +std::string hashBytes(const std::uint8_t* data, std::size_t len); + +// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float +// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all +// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). +// +// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a +// `bext` chunk containing the origination date/time) even when the format config blob +// requests no BWF metadata. Two renders of identical audio therefore differ in those +// bytes, making whole-file hashes diverge and preventing dedup collapse. +// +// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before +// the fmt/data bytes are fed in, so a content hash can never equal a whole-file +// hashBytes result for a different file of the same size. +// +// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a +// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — +// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an +// unrecognized or malformed file still gets a non-empty hash rather than silently +// skipping dedup. +// +// Called by both capture commit paths (offline and realtime) and the ingest import +// in place of the raw hashBytes call. Walks the container with the SAME chunk +// traversal parseWavLayout uses, so hashing and decoding can never desynchronize. +std::string hashWavContent(const std::vector& bytes); + +} // namespace reasampler::capture diff --git a/src/core/capture/wav_trim.cpp b/src/core/capture/wav_trim.cpp deleted file mode 100644 index 3fbe945..0000000 --- a/src/core/capture/wav_trim.cpp +++ /dev/null @@ -1,160 +0,0 @@ -// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor. - -#include "core/capture/wav_trim.h" - -#include // std::memcpy, std::memcmp - -namespace reasampler::capture { - -namespace { - -// Little-endian readers. Bounds are checked by the caller before each read; these -// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. -std::uint16_t readU16LE(const std::vector& b, std::size_t off) { - return static_cast(b[off] | (b[off + 1] << 8)); -} -std::uint32_t readU32LE(const std::vector& b, std::size_t off) { - return static_cast(b[off]) | - (static_cast(b[off + 1]) << 8) | - (static_cast(b[off + 2]) << 16) | - (static_cast(b[off + 3]) << 24); -} - -bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { - return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; -} - -// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION). -constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; -constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; - -} // namespace - -WavLayout parseWavLayout(const std::vector& bytes) { - WavLayout out; - - // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. - if (bytes.size() < 12) return out; - if (!tagEquals(bytes, 0, "RIFF")) return out; - if (!tagEquals(bytes, 8, "WAVE")) return out; - - bool haveFmt = false; - std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0; - std::uint32_t sampleRate = 0; - std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible - - // Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size), - // body padded to an even byte count (RIFF word alignment). Stop cleanly if a - // header would run past the buffer — a malformed/truncated file is "invalid", - // never an OOB read. - std::size_t pos = 12; - while (pos + 8 <= bytes.size()) { - const std::size_t bodyOffset = pos + 8; - const std::uint32_t bodySize = readU32LE(bytes, pos + 4); - - if (tagEquals(bytes, pos, "fmt ")) { - // fmt body: at least 16 bytes (PCM/float common fields). - if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out; - fmtTag = readU16LE(bytes, bodyOffset + 0); - channels = readU16LE(bytes, bodyOffset + 2); - sampleRate = readU32LE(bytes, bodyOffset + 4); - bitsPerSample = readU16LE(bytes, bodyOffset + 14); - // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading - // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM - // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to - // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in - // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). - if (fmtTag == kWaveFormatExtensible) { - if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) { - extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24); - } - } - haveFmt = true; - } else if (tagEquals(bytes, pos, "data")) { - // The data chunk: PCM starts at bodyOffset, declared length bodySize. - // Reject if it runs past the buffer (truncated / lying header). - if (bodyOffset + bodySize > bytes.size()) return out; - if (!haveFmt) return out; // data before fmt — not a WAV we parse - - // Plain IEEE-float tag (0x0003): accept as-is. - // Extensible tag (0xFFFE): accept only when the SubFormat tag read from - // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag - // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT - // float and must be rejected to prevent mis-decoding as float. - const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || - (fmtTag == kWaveFormatExtensible && - extensibleSubFormatTag == kWaveFormatIeeeFloat); - if (!floatTag || bitsPerSample != 32 || channels == 0) return out; - - out.valid = true; - out.channelCount = channels; - out.sampleRate = sampleRate; - out.dataByteOffset = bodyOffset; - out.dataByteLength = bodySize; - out.riffSizeFieldOffset = 4; - out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32) - return out; - } - - // Advance past this chunk's body, honoring RIFF even-byte padding. Guard the - // additions against size_t overflow (a hostile bodySize near SIZE_MAX). - std::size_t advance = bodySize; - if (advance & 1u) ++advance; // pad byte - if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop - pos = bodyOffset + advance; - } - - return out; // no data chunk found -> invalid -} - -std::vector extractFloatFrames(const std::vector& bytes, - const WavLayout& layout, - std::size_t startFrame, - std::size_t frameCount) { - std::vector out; - if (!layout.valid) return out; - - const std::size_t bytesPerFrame = - static_cast(layout.channelCount) * 4u; - const std::size_t totalFrames = layout.frameCount(); - if (startFrame >= totalFrames) return out; - - // Clamp the requested span to the frames that actually exist. - const std::size_t avail = totalFrames - startFrame; - const std::size_t frames = (frameCount < avail) ? frameCount : avail; - if (frames == 0) return out; - - const std::size_t firstByte = - layout.dataByteOffset + startFrame * bytesPerFrame; - out.resize(frames * layout.channelCount); - // memcpy each float (LE on target hosts — see header's byte-order note). - for (std::size_t i = 0; i < out.size(); ++i) { - float f = 0.0f; - std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); - out[i] = f; - } - return out; -} - -WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { - WavTruncatePlan plan; - if (!layout.valid) return plan; - - const std::size_t totalFrames = layout.frameCount(); - if (keptFrames > totalFrames) return plan; // never grow - - const std::size_t bytesPerFrame = - static_cast(layout.channelCount) * 4u; - const std::size_t keptDataBytes = keptFrames * bytesPerFrame; - - plan.valid = true; - plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; - plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; - plan.newDataSize = static_cast(keptDataBytes); - plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; - // RIFF size counts everything after the 8-byte "RIFF"+size prefix. - plan.newRiffSize = static_cast(plan.newFileByteLength - 8); - return plan; -} - -} // namespace reasampler::capture diff --git a/src/core/capture/wav_trim.h b/src/core/capture/wav_trim.h index 497790d..e2d182f 100644 --- a/src/core/capture/wav_trim.h +++ b/src/core/capture/wav_trim.h @@ -1,103 +1,15 @@ #pragma once -// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim. +// wav_trim — TRANSITIONAL forwarding header (Q-W3, audit §4e WAV/RIFF consolidation). // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. +// The one pure owner of the WAV/RIFF byte format is now core/capture/wav_codec.{h,cpp} +// (chunk walker + layout parse + float32 build + size-field patch + content hash). +// Everything this header used to declare (WavLayout / parseWavLayout / +// extractFloatFrames / WavTruncatePlan / planWavTruncate) lives there, same +// namespace (reasampler::capture), same signatures — this include is a pure alias. // -// WHY THIS EXISTS (docs/product/capture-tail.md §The realtime path). The realtime -// backend records a generous tail window, then trims the trailing decay by -// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is -// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk -// size and the `data` sub-chunk size) must be patched to the kept byte count, or -// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking, -// format verification, and the size-field patch offsets — is exactly the fiddly, -// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER -// shell (capture_realtime.cpp) does only the file I/O: read the bytes, call the -// pure parse, run the decay scan, call the pure plan, write the truncated bytes. -// -// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV -// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project -// record format, which the manual procedure sets to WAV/32-bit-float). This parser -// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt ` -// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE -// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything -// else (a different depth, a non-WAV, a compressed source) is reported invalid and -// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a -// file it does not understand. This is deliberately conservative. +// Kept ONLY so the TUs a parallel wave owns (sample_map.h and the VST editor/ +// processor god-TUs, Q-W2v) compile untouched — editing them here would collide +// with that wave's in-flight split. Retire this header (and point its includers at +// wav_codec.h) once Q-W2v lands. -#include -#include -#include - -#include "core/audio/peaks.h" // AudioSample (float) - -namespace reasampler::capture { - -using audio::AudioSample; - -// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the -// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field -// is meaningful only when valid. -struct WavLayout { - bool valid = false; - - std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) - std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) - - // The `data` chunk: byte offset of its first PCM byte within the file, and its - // declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). - std::size_t dataByteOffset = 0; - std::size_t dataByteLength = 0; - - // Byte offset of the two little-endian uint32 size fields the truncate patch - // rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk - // size (the 4 bytes immediately before dataByteOffset). - std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file - std::size_t dataSizeFieldOffset = 0; - - std::size_t frameCount() const { - const std::size_t bytesPerFrame = static_cast(channelCount) * 4u; - return bytesPerFrame ? dataByteLength / bytesPerFrame : 0; - } -}; - -// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything -// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, -// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only -// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). -WavLayout parseWavLayout(const std::vector& bytes); - -// Copies `frameCount` interleaved float frames starting at `startFrame` out of the -// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). -// Clamps to the frames the buffer actually holds — never reads past `data`. Returns -// empty for an invalid layout or an out-of-range start. The floats are read -// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would -// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux -// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. -std::vector extractFloatFrames(const std::vector& bytes, - const WavLayout& layout, - std::size_t startFrame, - std::size_t frameCount); - -// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte -// length and the two size-field values to patch. `valid` is false if the layout is -// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller -// clamps beforehand; this guards it too). -struct WavTruncatePlan { - bool valid = false; - - std::size_t newFileByteLength = 0; // truncate the file to exactly this length - std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32) - std::uint32_t newDataSize = 0; // kept PCM byte length - std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32) - std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes - // the 8-byte "RIFF"+size prefix) -}; - -// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. -// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + -// total. The shell applies it: patch the two size fields in the byte buffer, then -// truncate the file to newFileByteLength. -WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); - -} // namespace reasampler::capture +#include "core/capture/wav_codec.h" diff --git a/src/ingest.cpp b/src/ingest.cpp index 6496abc..bfc6d80 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -22,13 +22,13 @@ #include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex #include "core/model/bank_model.h" // Sample, AddResult, findByHash #include "shell/panel/panel_input.h" // bankPanelRefresh -#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) #include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "persist.h" // ReaSamplerSession -#include "core/capture/wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path +#include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) @@ -96,71 +96,11 @@ bool writeFileBytes(const std::string& path, const std::vector& by return f.good(); } -// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples. -// The output is a canonical WAV the bank and wav_trim can read: -// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body), -// data chunk (interleaved little-endian float32, one float per sample per channel). -// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch). -// Each ReaSample (double) is narrowed to float by assignment — the instrument expects -// 32-bit float; the reduction is intentional and matches how the bank contract is defined -// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION). -std::vector buildFloat32Wav(int nch, std::uint32_t rate, - std::size_t frameCount, - const std::vector& interleaved) { - const std::size_t sampleCount = frameCount * static_cast(nch); - const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32 - - // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload. - // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount. - const std::uint32_t riffSize = - static_cast(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data - - std::vector out; - out.reserve(44u + dataBytesCount); - - auto putU16 = [&](std::uint16_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - }; - auto putU32 = [&](std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); - }; - auto putTag = [&](const char* t) { - for (int i = 0; i < 4; ++i) - out.push_back(static_cast(t[i])); - }; - auto putF32 = [&](float f) { - std::uint8_t tmp[4]; - std::memcpy(tmp, &f, 4); - for (int i = 0; i < 4; ++i) out.push_back(tmp[i]); - }; - - // RIFF header - putTag("RIFF"); - putU32(riffSize); - putTag("WAVE"); - - // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003) - putTag("fmt "); - putU32(16u); // chunk body size - putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT - putU16(static_cast(nch)); - putU32(rate); - putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec - putU16(static_cast(nch * 4)); // blockAlign - putU16(32u); // bitsPerSample - - // data chunk - putTag("data"); - putU32(static_cast(dataBytesCount)); - for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) - putF32(static_cast(interleaved[i])); - - return out; -} +// The 32f WAV build itself lives in the pure wav_codec module (Q-W3, audit §4e / +// T4-10 — one owner of the RIFF layout, CTest-covered): buildFloat32Wav takes the +// interleaved ReaSample (double) frames decoded below and yields the canonical +// bank-format bytes (capture.cpp kRenderFormatWavFloat32; wav_codec.h FORMAT +// ASSUMPTION — the double→float narrowing is the intentional bank contract). // Decodes ALL samples from `src` into interleaved double-precision frames. // Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0). diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 0269c07..377f74a 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -1,5 +1,5 @@ -#include "core/namespaces.h" -// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend). +// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus +// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders). // // Compiled into the reaper_reasampler MODULE. Includes // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU @@ -36,6 +36,7 @@ #include "shell/capture/capture.h" +#include #include #include #include @@ -44,6 +45,7 @@ #include #include "core/capture/capture_paths.h" +#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner #include "core/util/file_bytes.h" #include "core/capture/render_settings.h" @@ -58,7 +60,7 @@ #define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime #include "reaper_plugin_functions.h" -namespace reasampler { +namespace reasampler::capture { namespace { @@ -219,21 +221,84 @@ struct ScopedRenderSettings { ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete; }; -// A monotonic, filesystem-safe timestamp tag so repeated captures in one session -// do not collide on the file name. NOTE: the tag varies the file NAME, not the -// audio bytes — bit-identical-repeat is about identical *content* for identical -// requests; two deliberate captures naturally live in two files. -std::string makeUniqueTag() { - std::time_t now = std::time(nullptr); - return std::to_string(static_cast(now)); -} - // Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): // empty on any I/O failure (the caller then leaves contentHash empty — the safe, // confirm-eliciting direction for an unreadable file). } // namespace +// --- Shared backend helpers (Q-W3 riders — see capture.h) -------------------- + +std::string makeUniqueTag(const std::string& prefix) { + // Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone + // had one-second resolution: two captures of the same baseName within the same + // wall-clock second derived the same file stem, so the second render silently + // overwrote the first file and minted two Samples with colliding ids — + // reachable in practice via batch capture. The counter (shared across both + // backends — this is the one definition both call) makes every tag of a + // session distinct regardless of timing. NOTE: the tag varies the file NAME, + // not the audio bytes — bit-identical-repeat is about identical *content* for + // identical requests; two deliberate captures naturally live in two files. + static std::atomic counter{0}; + const std::time_t now = std::time(nullptr); + return prefix + std::to_string(static_cast(now)) + "-" + + std::to_string(++counter); +} + +void stampCaptureSample(Sample& s, const CaptureRequest& req, + ReaProject* rateProj, ReaProject* timeSigProj, + const std::string& absolutePath) { + // Track GUIDs + channel count: echoed from the request (the caller resolved + // the selection; the backends stay source-agnostic). + s.trackGuids = req.trackGuids; + s.channelCount = req.channelCount; + + // Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read + // from the caller's project handle. PROJECT_SRATE can read 0 on a project that + // never explicitly pinned a rate — the value stays 0 (the Sample zero-value) + // rather than a bogus literal (the honest "unknown" both backends shared). + s.sampleRate = (req.sampleRate > 0) + ? req.sampleRate + : static_cast(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false)); + + s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) + + // Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime + // (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time, + // int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at + // that project time, so a sample captured under 3/4 keeps a 3/4 read-out even + // if the project later switches to 4/4. `timeSigProj` is the CALLER's project + // pin — offline passes nullptr (the active project); realtime pins the record's + // own project (the T2-09 divergence, kept caller-visible as this argument). + // tempoOut is ignored — captureTempo already carries the master tempo. Leaves + // 0/0 (unstamped) if the API is somehow unavailable; the formatter renders a + // blank musical read-out. + { + int tsNum = 0, tsDenom = 0; + double tsTempo = 0.0; + TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo); + s.captureTimeSigNum = tsNum; + s.captureTimeSigDenom = tsDenom; + } + + // Content hash: WAV-aware FNV-1a over the finished file's fmt+data chunks so + // hashReferencedElsewhere can identify copies in other banks and suppress the + // last-reference confirm when another bank still holds the same file. Using + // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks + // (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders/records of + // identical audio collapse to the same hash. Best-effort: an unreadable file + // leaves contentHash empty — the safe, confirm-eliciting direction (bank_model + // treats "" as non-participating in dedup). + { + const std::vector fileBytes = util::readFileBytes(absolutePath); + if (!fileBytes.empty()) { + s.contentHash = hashWavContent(fileBytes); + } + } + + s.createdTimestamp = static_cast(std::time(nullptr)); +} + CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { CaptureResult result; @@ -340,9 +405,9 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { }(); // Compute the unique tag ONCE so the file stem and Sample.id carry the same - // timestamp. Calling makeUniqueTag() twice could yield different values if a - // second boundary crosses between the two calls (bug: id and filename diverge). - const std::string uniqueTag = makeUniqueTag(); + // tag. Calling makeUniqueTag() twice would yield different values (the counter + // advances per call — bug: id and filename diverge). + const std::string uniqueTag = makeUniqueTag(""); const BankPaths paths = deriveBankPaths(projectDir, request.baseName, uniqueTag); @@ -477,50 +542,16 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // Seconds are the authoritative source for the render. Do NOT add DAW- // unverifiable PPQ resolution here — it requires a live REAPER to validate. s.wetDry = request.wetDry; - // Track GUIDs for track-scoped captures (empty for master/items/razor). The - // caller resolved the selection to canonical GUID strings; we record them so a - // "re-capture from source" (M10) knows which tracks the sample came from. - s.trackGuids = request.trackGuids; - s.channelCount = request.channelCount; - // Store the resolved sample rate only when it is known (> 0). If the project - // never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE - // either, so the render ran at REAPER's project default — an unknown value from - // this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather - // than store a bogus literal; M6/M7 can fill it in by probing the rendered file. - s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown s.lengthSeconds = request.endSeconds - request.startSeconds; - s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) - // Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime - // (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time, - // int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that - // project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the - // project later switches to 4/4. proj=nullptr => the active project (matches the - // Master_GetTempo() call above, which is also active-project). The tempoOut is - // ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped) - // if the API is somehow unavailable; the formatter renders a blank musical read-out. - { - int tsNum = 0, tsDenom = 0; - double tsTempo = 0.0; - TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo); - s.captureTimeSigNum = tsNum; - s.captureTimeSigDenom = tsDenom; - } - s.tier = Tier::Scratch; // captures land in scratch by default - // Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so - // hashReferencedElsewhere can identify copies in other banks and suppress the - // last-reference confirm when another bank still holds the same file. Using - // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks - // (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical - // audio collapse to the same hash. Best-effort: an unreadable file leaves - // contentHash empty — the safe, confirm-eliciting direction (bank_model treats - // "" as non-participating in dedup, which is the existing fallback semantics). - { - const std::vector fileBytes = readFileBytes(expectedPath); - if (!fileBytes.empty()) { - s.contentHash = hashWavContent(fileBytes); - } - } - s.createdTimestamp = static_cast(std::time(nullptr)); + s.tier = model::Tier::Scratch; // captures land in scratch by default + // The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount + // (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) — + // 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE + // either, so the render ran at REAPER's default), captureTempo, the capture- + // start time signature (timeSigProj = nullptr => the active project — matching + // the Master_GetTempo read, which is also active-project), the WAV-aware + // contentHash of the rendered file, and createdTimestamp. + stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath); // Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a // master mix / track / time-selection is not a single played note, so no root // note is derivable here — we do NOT guess one. Loop points are set later by an @@ -537,4 +568,4 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // guard's dtor restores every RENDER_* setting here. } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index fd9a54d..6fbb964 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -1,19 +1,23 @@ #pragma once -#include "core/namespaces.h" // capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). // // This header declares the capture *seam* the later milestones fill: // * CaptureRequest — everything a capture needs, source-mode-agnostic. -// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements -// (headless, immediate, returns a finished Sample). -// * OfflineRenderBackend — the deterministic default; drives the offline scopes. +// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class +// (the former ICaptureBackend interface was deleted in +// Q-W3, T4-26 — it had one deriver and zero polymorphic +// call sites; every construction site instantiates the +// concrete type). // * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven -// across timer ticks; deliberately NOT an ICaptureBackend +// across timer ticks; a genuinely different lifecycle // (see the SEAM CHOICE note at its declaration). +// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared +// finished-capture metadata stamp both backends call +// (Q-W3 riders T1-11 / T2-09). // // It includes bank_model (pure) to hand back a populated Sample, but NO REAPER // headers — the .cpp is the REAPER-facing translation unit. Keeping this header -// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam +// REAPER-free lets callers (the capture orchestration TUs) depend on the seam // without dragging the SDK into every include site. #include @@ -23,13 +27,17 @@ #include "core/model/bank_model.h" #include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract -// MediaTrack is forward-declared (like track_guid.h) so this header stays -// REAPER-free while RealtimeRecordBackend::begin can take the resolved source -// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a -// pure/header context; only the REAPER-facing capture_realtime.cpp touches them. +// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header +// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source +// MediaTrack* to tap and stampCaptureSample can take the project handles its reads +// pin. The pointers are opaque here — never dereferenced in a pure/header context; +// only the REAPER-facing capture TUs touch them. class MediaTrack; +class ReaProject; -namespace reasampler { +namespace reasampler::capture { + +using model::Sample; // Audio bit-depth for the rendered wav. 32-bit float is the M3 default — // rationale lives in capture.cpp next to the sink-config bytes. @@ -106,27 +114,48 @@ struct CaptureResult { std::string message; // human-readable detail for the console log }; -// The capture seam. One method: run a request, return a populated Sample (or a -// failure code). Backends are non-destructive — they must restore any global -// state they touch before returning (OfflineRenderBackend snapshots/restores the -// RENDER_* project settings). -class ICaptureBackend { -public: - virtual ~ICaptureBackend() = default; - virtual CaptureResult capture(const CaptureRequest& request) = 0; -}; - // Deterministic offline-render backend. Drives the full offline source family — // master mix / time selection, selected tracks, selected items, razor area — all // wet-only (render_settings.h) with optional tail. The source selection + range // are resolved by the caller (the action layer) and handed in via the // CaptureRequest; the backend drives RENDER_* and never reads the DAW selection // itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend). -class OfflineRenderBackend : public ICaptureBackend { +// Non-destructive: restores every RENDER_* setting it touches on every path. +// A plain concrete class — the former ICaptureBackend interface was deleted +// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async +// realtime backend deliberately never implemented it (see SEAM CHOICE below). +class OfflineRenderBackend { public: - CaptureResult capture(const CaptureRequest& request) override; + CaptureResult capture(const CaptureRequest& request); }; +// --- Shared backend helpers (Q-W3 riders) ------------------------------------ + +// Mints the filesystem-safe disambiguating tag for one capture's file stem + +// Sample id: "-" where is a PER-SESSION +// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision +// window: two captures of the same baseName within one second derived the same +// stem, so the second render silently overwrote the first file (reachable via +// batch capture driving short renders back-to-back). The counter makes every tag +// of a session distinct regardless of timing. `prefix` is the backend's family +// marker ("" offline, "rt-" realtime). +std::string makeUniqueTag(const std::string& prefix); + +// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp +// was copy-pasted per backend and had silently diverged): trackGuids + +// channelCount (echoed from the request), the resolved sampleRate (request rate, +// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo +// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime +// against `timeSigProj` — the offline path passes nullptr = active project, the +// realtime path pins the record's own project; the divergence stays caller-visible +// as this argument), the WAV-aware contentHash of the finished file at +// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting +// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds, +// tier, realtime's recorded-length override) stay with each caller. +void stampCaptureSample(Sample& s, const CaptureRequest& req, + ReaProject* rateProj, ReaProject* timeSigProj, + const std::string& absolutePath); + // --- Realtime-record backend: the ASYNC seam --------------------------------- // // A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport @@ -137,16 +166,17 @@ public: // from the same OnTimer that runs session.poll()) advances the in-flight record and // reports when it is done. // -// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the -// synchronous ICaptureBackend — that interface returns a finished Sample from one -// call, which no longer fits a record that spans ticks. The two backends have -// genuinely different lifecycles (offline is headless + immediate; realtime is -// transport-driven + async), so forcing a shared async interface would make offline -// fake a lifecycle it does not have (its tick() would always be Done on the first -// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the -// realtime backend owns this small bespoke async seam, driven by exactly one caller -// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified -// async interface for that reason. +// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The +// lifecycles are genuinely different (offline is headless + immediate — one +// synchronous capture() call returns a finished Sample; realtime is +// transport-driven + async — begin/tick/abort across timer ticks), so a shared +// interface would make offline fake a lifecycle it does not have (its tick() +// would always be Done on the first call — dead code / an LSP smell). Offline +// stays synchronous; the realtime backend owns this small bespoke async seam, +// driven by exactly one caller (the timer-driven realtime_lifecycle). This is the +// split-sync/async fork, chosen over a unified async interface for that reason. +// (The old synchronous ICaptureBackend interface over OfflineRenderBackend was +// deleted in Q-W3 — T4-26: one deriver, zero polymorphic call sites.) // One tick's verdict from the in-flight record. enum class RealtimeTickStatus { @@ -163,9 +193,8 @@ struct RealtimeTickResult { // The opaque in-flight capture state. Owns the snapshot of everything to restore // (temp track + its receive sends from the source tracks, other tracks' I_RECARM, // transport, edit cursor, time selection) and the record's own project handle. -// Defined in -// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject* -// leaks here) by holding it behind a forward-declared type + unique_ptr. +// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is +// dereferenced here) by holding it behind a forward-declared type + unique_ptr. // // restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope // RAII guard) because the record spans ticks — no single stack frame outlives it. @@ -173,10 +202,10 @@ struct RealtimeTickResult { // funnels through the same single restore, safe to call once from whichever fires. class RealtimeCaptureState; -// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque -// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is -// compiled in capture_realtime.cpp where the type is complete, keeping this header -// REAPER-free (load-bearing split). +// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the +// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the +// delete is compiled in capture_realtime_shell.cpp where the type is complete, +// keeping this header REAPER-free (load-bearing split). struct RealtimeCaptureStateDeleter { void operator()(RealtimeCaptureState* p) const noexcept; }; @@ -232,4 +261,4 @@ public: RealtimeTickResult abort(RealtimeCaptureState& state); }; -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp new file mode 100644 index 0000000..9893817 --- /dev/null +++ b/src/shell/capture/capture_batch.cpp @@ -0,0 +1,523 @@ +// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source +// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded +// as a parameter). See the header. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/capture_batch.h" + +#include +#include +#include +#include +#include + +#include "bank_panel.h" // bankPanelSelectedSampleIds / Refresh +#include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome +#include "core/model/bank_book.h" // BankBook / Bank +#include "core/model/provenance.h" // recipe parse/build, fingerprint +#include "persist.h" // ReaSamplerSession +#include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline +#include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid +#include "shell/capture/scope_resolve.h" // ResolvedSource +#include "shell/capture/track_guid.h" // guidString + +#include "reaper_plugin.h" // UNDO_STATE_MISCCFG + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItem_Track +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_CountMediaItems +#define REAPERAPI_WANT_GetMediaItem +#define REAPERAPI_WANT_SetMediaItemSelected +#define REAPERAPI_WANT_UpdateArrange +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_CountSelectedTracks +#define REAPERAPI_WANT_GetSelectedTrack +#define REAPERAPI_WANT_SetTrackSelected +#define REAPERAPI_WANT_SetOnlyTrackSelected +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +// --- M11: batch capture (per selected item / per razor area) ---------------- +// +// One action fires N captures — one bank sample per selected item (item scope) or per +// razor area (track scope, each area's own range). Each individual capture honors every +// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan +// neutralize, relative paths, channel preservation) and M10 provenance stamping applies +// per capture where its detection rule matches. The load-bearing principle holds: each +// unit writes a file + a bank index entry ONLY; nothing lands in the arrange. +// +// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1", +// "item-2", ...) so two units are never asked to write the same stem within one +// batch, and the shared makeUniqueTag now appends a per-session monotonic counter +// (T1-11 fix) so even same-second units across batches cannot collide. + +namespace { + +// RAII snapshot/restore of the project's media-item selection. Batch item capture must +// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is +// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including +// a mid-batch failure or early return — because selection restoration is part of the +// non-destructive invariant. Snapshot on construct (the currently-selected item set), +// restore on destruct (deselect everything, then re-select exactly the snapshot). +class ItemSelectionGuard +{ +public: + ItemSelectionGuard() + { + const int n = CountSelectedMediaItems(nullptr); + for (int i = 0; i < n; ++i) + if (MediaItem* it = GetSelectedMediaItem(nullptr, i)) + selected_.push_back(it); + } + + ~ItemSelectionGuard() + { + // Deselect every item in the project, then re-select the snapshot — restoring the + // exact original set regardless of what the batch selected in between. Iterate ALL + // items (not just the currently-selected) so any transient selection is cleared. + const int total = CountMediaItems(nullptr); + for (int i = 0; i < total; ++i) + if (MediaItem* it = GetMediaItem(nullptr, i)) + SetMediaItemSelected(it, false); + for (MediaItem* it : selected_) + SetMediaItemSelected(it, true); + UpdateArrange(); // reflect the restored selection in the arrange view + } + + ItemSelectionGuard(const ItemSelectionGuard&) = delete; + ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete; + +private: + std::vector selected_; +}; + +// Selects exactly `item` (deselect-all then select-one) so the offline render's +// selected-items bit (&32) captures a single item. Used inside the batch loop under the +// ItemSelectionGuard, which restores the user's original selection afterward. +void selectOnlyItem(MediaItem* item) +{ + const int total = CountMediaItems(nullptr); + for (int i = 0; i < total; ++i) + if (MediaItem* it = GetMediaItem(nullptr, i)) + SetMediaItemSelected(it, it == item); +} + +// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving +// track order then area order — the batch analog of resolveRazorRange, which unions them. +// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser. +std::vector> collectRazorAreas() +{ + std::vector> areas; + const int n = CountTracks(nullptr); + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetTrack(nullptr, i); + if (!tr) continue; + std::vector buf(8192, '\0'); + if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) + continue; + for (const RazorRange& r : parseRazorEdits(std::string(buf.data()))) + areas.push_back({tr, r}); + } + return areas; +} + +// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must +// transiently select exactly the area's owning track per render (track scope's &128 bit +// renders whatever TRACKS are selected); the user's original track selection is restored +// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard. +class TrackSelectionGuard +{ +public: + TrackSelectionGuard() + { + const int n = CountSelectedTracks(nullptr); + for (int i = 0; i < n; ++i) + if (MediaTrack* tr = GetSelectedTrack(nullptr, i)) + selected_.push_back(tr); + } + + ~TrackSelectionGuard() + { + // Deselect every track, then re-select the snapshot — the exact original set. + const int total = CountTracks(nullptr); + for (int i = 0; i < total; ++i) + if (MediaTrack* tr = GetTrack(nullptr, i)) + SetTrackSelected(tr, false); + for (MediaTrack* tr : selected_) + SetTrackSelected(tr, true); + } + + TrackSelectionGuard(const TrackSelectionGuard&) = delete; + TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete; + +private: + std::vector selected_; +}; + +} // namespace + +// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the +// selection (RAII restore on every path), then for each selected item transiently selects +// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the +// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for +// the whole batch). Reports a mixed-result summary (explicit-action response — allowed). +void RunBatchCaptureItems(ReaSamplerSession& session) +{ + // Read the selected items up front (pointers stay valid — batch mutates only selection + // flags, never adds/removes items). Also capture each item's exact bounds and owning + // track NOW, while the full selection is live, before any transient re-selection. + struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; + std::vector itemUnits; + { + const int n = CountSelectedMediaItems(nullptr); + for (int i = 0; i < n; ++i) + { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + MediaTrack* tr = GetMediaItem_Track(it); + if (!tr) continue; + const double pos = GetMediaItemInfo_Value(it, "D_POSITION"); + const double len = GetMediaItemInfo_Value(it, "D_LENGTH"); + itemUnits.push_back({it, tr, pos, pos + len}); + } + } + if (itemUnits.empty()) + { + ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n"); + return; + } + + // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ + // inverted item ranges (a zero-length item) are dropped here so no stray render runs. + std::vector ranges; + ranges.reserve(itemUnits.size()); + for (const ItemUnit& u : itemUnits) + ranges.push_back({u.start, u.end}); + const std::vector plan = planCaptureUnits(ranges); + + BatchOutcome outcome; + bool anyAdded = false; + { + // Restore the user's ORIGINAL item selection on every exit path (incl. early + // return / mid-batch failure) — non-destructive invariant. + ItemSelectionGuard selGuard; + + // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only + // for those whose range survived planning (same drop rule), matching by ordinal. + std::size_t planIdx = 0; + for (const ItemUnit& u : itemUnits) + { + if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep + const CaptureUnit& unit = plan[planIdx++]; + + // Transiently select ONLY this item so the item-scope render captures exactly it. + selectOnlyItem(u.item); + + ResolvedSource src; + src.startSeconds = unit.startSeconds; + src.endSeconds = unit.endSeconds; + src.sourceTracks.push_back(u.track); + if (std::string g = guidString(u.track); !g.empty()) + src.trackGuids.push_back(std::move(g)); + + const std::string baseName = "item-" + std::to_string(unit.ordinal); + CaptureResult res = captureAndIndexOne( + session, CaptureScope::Item, src, baseName, + unit.startSeconds, unit.endSeconds); + + const bool ok = (res.status == CaptureStatus::Ok); + outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); + if (ok) anyAdded = true; + } + } // selGuard restores the original selection here, on every path + + // Persist ONCE for the whole batch (one ext-state write) — only if something landed. + // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, + // so a single increment past the last-seen value is enough to trigger one instance reload. + if (anyAdded) { + session.bumpBankGeneration(); + session.saveToActiveProject(); + } + + ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); +} + +// Batch razor capture: one bank sample per razor AREA, track scope over that area's own +// range (the area's owning track is the source track). Track scope renders the selected +// TRACKS via master (&128), so each unit transiently selects ONLY its owning track +// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original +// track selection on every path. The razor selection itself is read-only and left intact. +// Persists ONCE at the end. Reports a mixed-result summary. +void RunBatchCaptureRazor(ReaSamplerSession& session) +{ + const std::vector> areas = collectRazorAreas(); + if (areas.empty()) + { + ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); + return; + } + + std::vector ranges; + ranges.reserve(areas.size()); + for (const auto& a : areas) + ranges.push_back({a.second.startSeconds, a.second.endSeconds}); + const std::vector plan = planCaptureUnits(ranges); + + BatchOutcome outcome; + bool anyAdded = false; + { + // Restore the user's ORIGINAL track selection on every exit path. + TrackSelectionGuard selGuard; + + std::size_t planIdx = 0; + for (const auto& a : areas) + { + if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep + const CaptureUnit& unit = plan[planIdx++]; + MediaTrack* tr = a.first; + + // Transiently select ONLY this track so the track-scope render (&128) captures + // exactly it via master (over the custom time bounds we set per unit). + SetOnlyTrackSelected(tr); + + ResolvedSource src; + src.startSeconds = unit.startSeconds; + src.endSeconds = unit.endSeconds; + src.sourceTracks.push_back(tr); + if (std::string g = guidString(tr); !g.empty()) + src.trackGuids.push_back(std::move(g)); + + const std::string baseName = "razor-" + std::to_string(unit.ordinal); + CaptureResult res = captureAndIndexOne( + session, CaptureScope::Track, src, baseName, + unit.startSeconds, unit.endSeconds); + + const bool ok = (res.status == CaptureStatus::Ok); + outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); + if (ok) anyAdded = true; + } + } // selGuard restores the original track selection here, on every path + + // S9: one coalesced bump for the whole razor batch (see the item-batch note above). + if (anyAdded) { + session.bumpBankGeneration(); + session.saveToActiveProject(); + } + + ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); +} + +// --- M10: re-capture from source -------------------------------------------- +// +// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT +// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and +// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the +// load-bearing capture-never-places line, structurally visible: this function has no +// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore +// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places +// manually if they want the new version on the timeline. +// +// Failure modes are handled explicitly and reported to the user (a direct response +// to an explicit action is allowed by the console policy): +// * the selected sample has no provenance (not a resample) -> reported, no-op. +// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. +// * the recorded source track(s) no longer exist -> reported, no-op. +// * the render itself fails to satisfy the recorded request -> reported, no-op. +// On success, if the source FX chain drifted since capture (recorded vs current +// identity differ) the user is told — the re-capture still reflects the source AS IT +// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). +void RunRecaptureFromSource(ReaSamplerSession& session) +{ + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) + { + ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n"); + return; + } + if (selected.size() > 1) + { + ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n"); + return; + } + const std::string sampleId = selected.front(); + + // Resolve the sample from the bank it lives in (the focused region's displayed bank). + const std::string srcBankId = bankPanelSelectedSourceBankId(); + const Bank* bank = session.book().bank(srcBankId); + const model::Sample* orig = bank ? bank->index.query(sampleId) : nullptr; + if (!orig) + { + ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n"); + return; + } + if (!orig->provenance) + { + ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance " + "(it was not resampled from a bank sample).\n"); + return; + } + + // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt + // string fails gracefully — never a partial re-capture. + const std::string recordedParentId = orig->provenance->parentSampleId; + const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; + const std::optional recipe = + model::parseFingerprint(recordedFingerprint); + if (!recipe) + { + ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable " + "(recorded by an older/incompatible build); cannot re-capture.\n"); + return; + } + + // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a + // hard failure — we will not silently re-capture a different source. + std::vector sourceTracks; + for (const std::string& g : recipe->trackGuids) + { + MediaTrack* tr = trackByGuid(g); + if (!tr) + { + ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer " + "exists in this project; cannot re-capture from source.\n"); + return; + } + sourceTracks.push_back(tr); + } + if (sourceTracks.empty()) + { + // The recipe recorded no source tracks (e.g. an item-scope capture whose source + // tracks were not track-scoped). Without a resolvable source we cannot re-run. + ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " + "sample; cannot re-capture from source.\n"); + return; + } + + const CaptureScope scope = + recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item + : CaptureScope::Track; + + // Rebuild the capture request verbatim from the recorded recipe — the SAME request, + // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, + // channels, bit depth all match the original so an unchanged source produces a + // byte-identical file (bit-identical-repeats invariant, consumed as a feature). + CaptureRequest req; + req.sourceMode = static_cast(recipe->sourceMode); + req.startSeconds = recipe->startSeconds; + req.endSeconds = recipe->endSeconds; + req.wetDry = 1.0; + req.tailMode = static_cast(recipe->tailMode); + req.tailMs = recipe->tailMs; + req.sampleRate = recipe->sampleRate; + req.channelCount = recipe->channelCount; + req.bitDepth = WavBitDepth::Float32; + req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; + req.trackGuids = recipe->trackGuids; + + // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to + // compare against the recorded identity for drift reporting. Mirror the same + // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; + // track scope reads the track FX chain via TrackFX_*. + std::string currentIdentity; + if (scope == CaptureScope::Item) { + const int n = CountSelectedMediaItems(nullptr); + std::vector items; + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (it) items.push_back(it); + } + currentIdentity = fxChainIdentityForItems(items); + } else { + std::vector perTrackNow; + perTrackNow.reserve(sourceTracks.size()); + for (MediaTrack* tr : sourceTracks) + perTrackNow.push_back(fxChainIdentityForTrack(tr)); + currentIdentity = model::combineChainIdentities(perTrackNow); + } + const bool drifted = (currentIdentity != recipe->fxChainIdentity); + + // Render (bank-only; renderOffline never touches the timeline). + CaptureResult res = renderOffline(scope, sourceTracks, req); + if (res.status != CaptureStatus::Ok) + { + ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str()); + return; + } + + // Update the Sample IN PLACE: keep its identity (id) and its provenance thread + // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but + // adopt the regenerated file's path / hash / length / rate / timestamp. The + // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a + // subsequent re-capture measures drift from this point, not the original. + model::CaptureRecipe refreshed = *recipe; + refreshed.fxChainIdentity = currentIdentity; + + model::Sample updated = *orig; // copy: preserves id, displayName, tier, key + updated.relativePath = res.sample.relativePath; + updated.contentHash = res.sample.contentHash; + updated.sourceMode = res.sample.sourceMode; + updated.sourceRange = res.sample.sourceRange; + updated.channelCount = res.sample.channelCount; + updated.sampleRate = res.sample.sampleRate; + updated.lengthSeconds = res.sample.lengthSeconds; + updated.captureTempo = res.sample.captureTempo; + updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp + updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter + updated.trackGuids = res.sample.trackGuids; + updated.createdTimestamp = res.sample.createdTimestamp; + // NOTE: levels, clipped, and lengthBeats are carried from the original (via the + // *orig copy above) because the offline backend does not populate them today + // (res.sample leaves them at defaults). If a later milestone populates these + // fields at capture time, refresh them here from res.sample instead. + model::Provenance prov; + prov.parentSampleId = recordedParentId; + prov.fxChainSnapshot = model::buildFingerprint(refreshed); + updated.provenance = prov; + + // Single batched undo point around the in-place bank mutation (mirrors the bank + // action family's R-B pattern). The mutation is index-only ext-state; the render + // wrote a new file but placed nothing on the timeline. + Undo_BeginBlock2(nullptr); + const bool changed = session.book().updateSampleInPlace(sampleId, updated); + if (changed) + { + // Record the regenerated file in the owned manifest (a new file the tool wrote); + // the superseded old file becomes an orphan reclaimed by Phase R prune. + session.owned().add(updated.relativePath); + // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the + // hands-free refresh exists for (an instance referencing this id keeps playing the + // OLD audio until it reloads). Bump inside the undo block so undo rolls back the + // generation with the rest of the blob. + session.bumpBankGeneration(); + const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty + Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", + persisted ? UNDO_STATE_MISCCFG : 0); + } + else + { + Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point + } + + bankPanelRefresh(); // reflect the regenerated file in the docked grid + + if (drifted) + ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the " + "original capture -- the sample was regenerated from the source's " + "current state.\n"); +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_batch.h b/src/shell/capture/capture_batch.h new file mode 100644 index 0000000..f161421 --- /dev/null +++ b/src/shell/capture/capture_batch.h @@ -0,0 +1,32 @@ +#pragma once +// capture_batch — the batch-capture family + re-capture-from-source (Q-W3 hoist +// out of main.cpp; the fourth hoist, T4-02 — recapture is planner-driven like +// batch and shares the RAII selection-guard machinery, so it belongs here, not +// with the single-shot path). Owns: +// * RunBatchCaptureItems — one bank sample per SELECTED item (item scope), the +// user's item selection snapshot/restored on every path (ItemSelectionGuard); +// * RunBatchCaptureRazor — one bank sample per razor AREA (track scope over the +// area's own range), the user's track selection snapshot/restored on every +// path (TrackSelectionGuard); +// * RunRecaptureFromSource — regenerate a PROVENANCED bank sample from its +// recorded source's CURRENT state, updating the Sample in place. BANK-ONLY. +// +// Every unit honors every precision invariant via capture_orchestrator's +// captureAndIndexOne / renderOffline (exact bounds, non-destructive neutralize, +// relative paths); nothing here ever touches the arrange/timeline (load-bearing +// principle). Persist is batched: ONE ext-state write per action. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +void RunBatchCaptureItems(ReaSamplerSession& session); +void RunBatchCaptureRazor(ReaSamplerSession& session); +void RunRecaptureFromSource(ReaSamplerSession& session); + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp new file mode 100644 index 0000000..c735bab --- /dev/null +++ b/src/shell/capture/capture_orchestrator.cpp @@ -0,0 +1,487 @@ +// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert +// action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session +// threaded as a parameter). See the header. FxBypassGuard lives here as a STACK +// RAII object (precision-invariant-critical — it must restore on every exit path +// of exactly one render call). +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/capture_orchestrator.h" + +#include +#include + +#include "bank_panel.h" // bankPanelTailSetting / bankPanelRefresh +#include "core/capture/tail_control.h" // TailSetting +#include "core/model/provenance.h" // model::Provenance +#include "ingest.h" // ingestAssignActiveInstance +#include "persist.h" // ReaSamplerSession +#include "shell/capture/insert.h" // runInsert / InsertRequest +#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state + +#include "reaper_plugin.h" // UNDO_STATE_MISCCFG + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetParentTrack +#define REAPERAPI_WANT_GetMasterTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) -- +// For every track a scope must NOT hear the FX of, this ALSO neutralizes that +// track's fader gain AND its full pan chain (pan/width/law/mode) for the render — +// because a Track/Item capture renders via master and would otherwise sum through +// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan +// coloring into the file (Daniel: the capture is likely re-routed through that +// same chain later, so parent/master level and pan must not be baked in). The +// neutralize set is IDENTICAL to the FX-bypass set: +// Item -> own track + all ancestors + master (take vol/pan kept: item content). +// Track -> all ancestors + master (selected track's OWN vol/pan kept). +// (Master is a bypass TARGET for both scopes — never a scope of its own.) +// +// Per track in that set we snapshot & set the full parent-chain-independence set, +// so a Track/Item capture is uncolored by the parent/folder/master it renders +// through — no FX, no fader, and no pan/width/law/mode coloring: +// I_FXEN -> 0 (FX bypassed; SDK ~2194) +// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB") +// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1") +// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1", +// 1.0 = full width = no narrowing/collapse) +// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain) +// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual") +// All are restored to their ORIGINAL values on EVERY exit path (RAII). +// +// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes +// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan) +// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR +// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the +// header does not state as such. Rather than snapshot two more mode-conditional +// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the +// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the +// original mode. This fully neutralizes pan for every original mode with no +// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.) +// +// Structurally non-destructive: no takes, no items, no project restructuring — +// only transient FX-enable + trim-volume toggles, always restored. +class FxBypassGuard +{ +public: + // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose + // ancestor chains (walked via GetParentTrack) + the master are bypassed per the + // plan. proj is the active project (for GetMasterTrack). + FxBypassGuard(CaptureScope scope, + const std::vector& sourceTracks, + ReaProject* proj) + { + const FxBypassPlan plan = fxBypassPlanFor(scope); + + for (MediaTrack* tr : sourceTracks) + { + if (!tr) continue; + if (plan.bypassSelfFx) bypass(tr); + if (plan.bypassAncestorFx) + { + // Walk parents to the top: GetParentTrack returns the immediate + // parent (folder) track, nullptr at the outermost level (SDK + // header ~2407). The master is NOT returned here — handled below. + for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p)) + bypass(p); + } + } + if (plan.bypassMaster) + { + // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass() + // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it + // just like any other in-scope track; only the master's summing/routing + // topology (the mix bus itself) remains — that is not a per-track param. + if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); + } + } + + ~FxBypassGuard() + { + // Restore in reverse for symmetry (order is not load-bearing — each track + // appears once, snapshots are independent). EVERY snapshotted param is + // restored to its ORIGINAL value on this (every) exit path. Restore + // I_PANMODE before the pan values so any mode-conditional params (e.g. dual + // pan) settle under the original mode. + for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) + { + SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); + SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol); + SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode); + SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan); + SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width); + SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw); + } + } + + FxBypassGuard(const FxBypassGuard&) = delete; + FxBypassGuard& operator=(const FxBypassGuard&) = delete; + +private: + // One snapshot per bypassed track: all params we neutralize, at their originals. + // panmode captures I_PANMODE so we can force stereo-pan for the render and put + // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when + // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to + // touch or guess neutral values for them. + struct Snap + { + MediaTrack* track; + double fxen; + double vol; + double pan; + double width; + double panlaw; + double panmode; + }; + std::vector snapshots_; + + // Snapshot every neutralized param once per track (dedup: an ancestor shared by + // two selected tracks must be restored to its ORIGINAL values, not to a + // re-snapshot of the already-neutralized state), then read ALL originals, push + // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain. + void bypass(MediaTrack* tr) + { + for (const Snap& s : snapshots_) if (s.track == tr) return; // already done + // Read ALL originals first (atomic snapshot), then push, then neutralize. + const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); + const double vol = GetMediaTrackInfo_Value(tr, "D_VOL"); + const double pan = GetMediaTrackInfo_Value(tr, "D_PAN"); + const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH"); + const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW"); + const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE"); + snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode}); + SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) + SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226) + SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231) + SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227) + SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228) + SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232) + } +}; + +} // namespace + +// Renders one CaptureRequest through the offline backend under the scope's +// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and +// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE +// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the +// render, and fully restored on every path (RAII). Non-destructive; touches no +// timeline item (load-bearing principle) — it writes a file only. +CaptureResult renderOffline(CaptureScope scope, + const std::vector& sourceTracks, + const CaptureRequest& req) +{ + ReaProject* proj = EnumProjects(-1, nullptr, 0); + FxBypassGuard fxGuard(scope, sourceTracks, proj); + OfflineRenderBackend backend; + return backend.capture(req); +} + +// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, +// and adds the resulting Sample to the ACTIVE bank + records the created file in the +// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture: +// right after; batch: once at the end) so a batch does not write ext state N times. +// +// Provenance is read from the LIVE selection here, so a batch that transiently +// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies +// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and +// provenance scope. Returns the backend's CaptureResult (status + message) so the +// caller can report success/failure. Load-bearing principle holds: writes a file + +// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the +// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), +// and the backend restores every RENDER_* setting. +// +// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id +// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 +// capture+assign path can target the sample actually in the bank. Batch callers ignore +// it; the plain capture actions are unaffected. +CaptureResult captureAndIndexOne(ReaSamplerSession& session, + CaptureScope scope, + const ResolvedSource& src, + const std::string& baseName, + double startSeconds, + double endSeconds) +{ + // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action + // variant: the capture actions apply whatever the panel is set to. Default is None + // (exact bounds / byte-identical to today) until the user opts in via the toggle. + const TailSetting tail = bankPanelTailSetting(); + + CaptureRequest req; + req.sourceMode = sourceModeForScope(scope); + req.startSeconds = startSeconds; // exact bounds — no rounding + req.endSeconds = endSeconds; + req.wetDry = 1.0; // wet post the FX left enabled by the scope + req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto + req.sampleRate = 0; // follow project rate + req.channelCount = 2; + req.bitDepth = WavBitDepth::Float32; // deterministic, no dither + req.baseName = baseName; + req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) + + // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain — + // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and + // the source selection is still live here. Returns nullopt unless this capture + // genuinely resamples from a bank sample (detectParent). Read-only. + const std::optional prov = + buildCaptureProvenance(session.book(), req, scope, src); + + // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain + // neutralized for the render, fully restored on every path). Writes a file only. + CaptureResult res = renderOffline(scope, src.sourceTracks, req); + if (res.status != CaptureStatus::Ok) + return res; + + // Stamp provenance onto the captured Sample (only set when this was a genuine + // resample-from-sample; otherwise the optional stays empty, per M1's contract). + res.sample.provenance = prov; + + // Add to the ACTIVE bank: session.bank() resolves to book.activeIndex() (B2). The + // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can + // target the sample actually in the bank (the existing entry on a collapse). + const model::AddResult addResult = session.bank().add(res.sample); + // B-cap: record the created file in the owned-file manifest, at the same point the + // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse + // still WROTE a file the tool owns, and the manifest dedups a repeat path itself + // (Phase R prune reconciles manifest vs index later). + session.owned().add(res.sample.relativePath); + + // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new + // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a + // Collapsed (the file we just rendered deduped onto an already-present sample — assign + // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged. + if (addResult == model::AddResult::Collapsed && !res.sample.contentHash.empty()) + { + if (const model::Sample* existing = + session.bank().findByHash(res.sample.contentHash)) + res.sample.id = existing->id; + } + return res; +} + +// Runs one capture-action-table row: resolve its scope source + range, render + add + +// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle +// holds structurally — this path writes a file + a bank index entry ONLY; it never +// calls InsertMedia or touches the arrange/timeline. +// Returns the bank-index id of the sample the capture landed on: the newly-added id on a +// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign +// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange +// capture+assign path reads this to write an assignment request; the plain capture actions +// ignore it (their behaviour is unchanged — capture still writes a file + index entry only). +std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def) +{ + ResolvedSource src; + std::string why; + if (!ResolveScopeSource(def.scope, src, why)) + { + ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); + return {}; + } + + CaptureResult res = captureAndIndexOne(session, def.scope, src, def.baseName, + src.startSeconds, src.endSeconds); + if (res.status != CaptureStatus::Ok) + { + ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); + return {}; + } + + // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE + // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). + // Persist the updated book AND manifest into the active project's ext state (the + // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and + // travels with the .rpp. saveToActiveProject also clears the retired legacy key and + // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. + // S9: a capture add is a bank-content change -> bump before the persist so an assigned + // live instance refreshes hands-free (the S8 capture+assign path builds on this). + session.bumpBankGeneration(); + session.saveToActiveProject(); + + // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne + // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a + // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above). + return res.sample.id; +} + +// S8 arrange ingest: capture the selected item / time-selection into the active bank +// (reusing the Item-scope capture path verbatim) and, on success, write an assignment +// request so the active sampler instance plays the new sample on its next reload. The +// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER +// inserts a timeline item (load-bearing principle); the only addition here is the +// bank-index-id -> assignment-request write after the sample lands. If the capture +// failed / no-op'd (empty id), no assignment is written (nothing to assign). +// +// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the +// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are +// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically. +// An undo that removes the captured sample also clears the assign_request that named it, +// preventing a stale request from pointing at a removed sample. The block uses the house +// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero +// flag) matching the bank-op family in actions.cpp. +void RunCaptureItemAssign(ReaSamplerSession& session) +{ + // Reuse the Item-scope def from the capture table (index 0) — same range logic, same + // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. + Undo_BeginBlock2(nullptr); + + const std::string sampleId = RunCapture(session, captureActionTable()[0]); + if (sampleId.empty()) + { + // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. + Undo_EndBlock2(nullptr, "", 0); + return; + } + + // Assign inside the same block so undo clears both keys together. + ingestAssignActiveInstance(session.book().activeBankId(), sampleId); + Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", + UNDO_STATE_MISCCFG); + + bankPanelRefresh(); + ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " + "instance.\n"); +} + +// STARTS the REALTIME track capture and returns immediately — the record runs across +// timer ticks (DriveRealtimeCapture in realtime_lifecycle), so REAPER's UI stays +// responsive. Resolves the selected tracks + the range (razor-else-time, the same +// orthogonal range logic as the offline scopes) and starts recording each selected +// track's OWN output into a hidden temp track via RealtimeRecordBackend::begin (a +// send FROM each source track INTO the temp — see capture_realtime_shell.cpp §TAP); +// OnTimer drives it to completion, then adds the Sample and persists. TRACK scope +// only this increment (item realtime is deferred). Dialog-free. Non-bit-identical +// by nature (it is realtime) — offline stays the deterministic default. +// FxBypassGuard is NOT used here — the track-output tap is PRE-parent by +// construction (§TAP), so there is no live chain to neutralize. The load-bearing +// principle holds structurally — this writes a file + a bank entry ONLY; the temp +// track is a transient sink removed by the backend, nothing lands in arrange. +// +// A SECOND realtime capture requested while one is in progress is REJECTED — the +// first keeps running (we own the transport for its window; starting a second would +// collide on the transport and the temp-track/arm snapshot). +void RunCaptureRealtimeTrack(ReaSamplerSession& session) +{ + (void)session; // start path persists nothing — commit happens on the terminal tick + if (g_rtCapture) + { + ShowConsoleMsg("ReaSampler realtime capture: a capture is already in " + "progress -- let it finish (or stop the transport) first.\n"); + return; + } + + // Resolve the selected tracks + range exactly as the offline Track scope does. + // No track selected -> refuse (same no-op as offline track scope). + ResolvedSource src; + std::string why; + if (!ResolveScopeSource(CaptureScope::Track, src, why)) + { + ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str()); + return; + } + + // The tail mode is the SAME panel setting the offline capture actions read (the + // docked bank panel's toggle). Realtime honors it via a parallel path: the backend + // records a generous window past the range end, then trims by PCM decay-scan (T2 / + // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None + // keeps realtime exact-bounds / byte-identical to today. + const TailSetting tail = bankPanelTailSetting(); + + CaptureRequest req; + req.sourceMode = SourceMode::SelectedTracks; // realtime track scope + req.startSeconds = src.startSeconds; // exact bounds — no rounding + req.endSeconds = src.endSeconds; + req.wetDry = 1.0; // fully wet (post-fader tap) + req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto + req.sampleRate = 0; // follow project rate + req.channelCount = 2; + req.bitDepth = WavBitDepth::Float32; + req.baseName = "realtime"; + req.trackGuids = src.trackGuids; // provenance on the Sample + + CaptureResult failure; + RealtimeCaptureHandle st = g_rtBackend.begin(req, src.sourceTracks, failure); + if (!st) + { + // begin() validated/failed and already restored anything it touched. + ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str()); + return; + } + + // Started. Store the in-flight state + its project; OnTimer drives it to + // completion across ticks (UI stays responsive). + g_rtCaptureProject = EnumProjects(-1, nullptr, 0); + g_rtCapture = std::move(st); +} + +// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates +// via abort() — stop the transport + restore ALL snapshotted state (non-destructive), +// committing whatever audio was already captured (best effort) so a cancel near the end +// still keeps the take. Runs only against the record's OWN project (abort() self-guards +// the closed-project case, review §1). No-op with a note when nothing is in flight. +void RunCancelRealtime(ReaSamplerSession& session) +{ + if (!g_rtCapture) + { + ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); + return; + } + RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); + if (r.status == RealtimeTickStatus::Done) + CommitRealtimeResult(session, r.result); // Ok: keep what was captured up to the cancel + else + ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " + + r.result.message + "\n").c_str()); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; +} + +// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor +// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match +// variant (never silent — it fires only from the distinct "conform" action). This +// is the INTENDED placement path: it adds items to the arrange on purpose +// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action. +void RunInsertSelected(ReaSamplerSession& session, bool conform) +{ + InsertRequest req; + // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the + // user's currently-selected track(s) at the edit cursor. + req.options.conform = conform ? TempoConform::Ratio1x : TempoConform::None; + // preservePitch stays true: a tempo conform matches tempo without varispeeding + // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.) + + InsertResult res = runInsert(&session, req); + + switch (res.status) + { + case InsertStatus::Ok: + break; // success — no console chatter + case InsertStatus::NoSelection: + // "select a track first" is printed by runInsert when no track is + // selected; this branch covers the no-panel-selection case. + ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n"); + break; + case InsertStatus::NoProject: + ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); + break; + case InsertStatus::NothingResolved: + ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); + break; + } +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_orchestrator.h b/src/shell/capture/capture_orchestrator.h new file mode 100644 index 0000000..d5d9b3b --- /dev/null +++ b/src/shell/capture/capture_orchestrator.h @@ -0,0 +1,73 @@ +#pragma once +// capture_orchestrator — the single-capture orchestration + the realtime/insert +// action bodies (Q-W3 hoist out of main.cpp, T4-02). Owns: +// * renderOffline — ONE offline render under the scope's FxBypassGuard (the +// stack-RAII out-of-scope FX/fader/pan neutralize, defined in the .cpp — +// precision-invariant-critical, shared by single-shot / batch / recapture); +// * captureAndIndexOne — render + provenance stamp + bank add + owned-manifest +// record, WITHOUT persisting (single-shot persists right after; batch persists +// once at the end); +// * RunCapture / RunCaptureItemAssign — the bindable single-capture actions; +// * RunCaptureRealtimeTrack / RunCancelRealtime — the realtime action bodies +// (the in-flight state itself lives in realtime_lifecycle); +// * RunInsertSelected — the M6 placement action body (the INTENDED, explicit +// placement path — the one deliberate exception to capture-never-places). +// +// The session is threaded explicitly (no hidden module state): main.cpp's dispatch +// passes its ReaSamplerSession. The load-bearing principle holds structurally — +// no capture path here calls InsertMedia or touches the arrange/timeline; only +// RunInsertSelected places, on purpose, via the insert shell. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include + +#include "shell/capture/capture.h" // CaptureResult / CaptureRequest +#include "shell/capture/scope_resolve.h" // ResolvedSource +#include "core/capture/render_settings.h" // CaptureScope, CaptureActionDef + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +// Renders one CaptureRequest through the offline backend under the scope's +// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and +// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE +// place. Non-destructive; touches no timeline item — it writes a file only. +CaptureResult renderOffline(CaptureScope scope, + const std::vector& sourceTracks, + const CaptureRequest& req); + +// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, +// and adds the resulting Sample to the ACTIVE bank + records the created file in +// the owned-file manifest — WITHOUT persisting. On success, res.sample.id carries +// the LANDED bank-index id (fresh add or hash-dedup collapse target — S8). +CaptureResult captureAndIndexOne(ReaSamplerSession& session, + CaptureScope scope, + const ResolvedSource& src, + const std::string& baseName, + double startSeconds, + double endSeconds); + +// Runs one capture-action-table row: resolve, render + add + record, persist + +// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the S8 +// capture+assign path consumes it; the plain capture actions ignore it. +std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def); + +// S8 arrange ingest: Item-scope capture into the active bank + assignment-request +// write, in one undo block. +void RunCaptureItemAssign(ReaSamplerSession& session); + +// STARTS the realtime track capture (async, timer-driven — the in-flight state is +// realtime_lifecycle's; OnTimer drives it) / cancels the in-flight one. +void RunCaptureRealtimeTrack(ReaSamplerSession& session); +void RunCancelRealtime(ReaSamplerSession& session); + +// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor +// via the insert shell. `conform` selects the explicit opt-in tempo-match variant. +void RunInsertSelected(ReaSamplerSession& session, bool conform); + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp new file mode 100644 index 0000000..c2a5e9f --- /dev/null +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -0,0 +1,252 @@ +// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell +// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM +// decay-scan trim, and the finished-Sample population. See the header. The async +// record lifecycle lives in capture_realtime_shell.cpp. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/capture_realtime_finalize.h" + +#include +#include +#include +#include +#include + +#include "core/audio/peaks.h" // lastFrameAboveThreshold +#include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture +#include "core/capture/render_settings.h" // autoTrimEndRatio +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetTrackNumMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_GetMediaItemTake +#define REAPERAPI_WANT_GetMediaItemTake_Source +#define REAPERAPI_WANT_GetMediaSourceFileName +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +std::string normSlashes(std::string s) { + for (char& c : s) if (c == '\\') c = '/'; + if (s.size() > 1 && s.back() == '/') s.pop_back(); + return s; +} + +// ============================================================================ +// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) +// ============================================================================ +// After the recorded file is stable and moved into the bank (the file we OWN — never +// the project), Auto mode trims the trailing decay: read the WAV, scan the tail +// region (frames AFTER the original range end) backward for the last frame above +// -72 dB, and truncate the file there. Rules (spec): +// * no frame in the tail window above -72 dB -> trim back to the original range end +// * signal never falls below -72 dB in window -> keep the full window (cap did its job) +// * otherwise -> trim one frame past the last audible +// +// Returns the trimmed length in SECONDS (for the Sample), or a negative value to +// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and +// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) +// rather than risk corrupting the capture — realtime tail is a convenience path. +// +// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit +// float WAV (REAPER project record format — the manual procedure sets it) and is fully +// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees +// that for the normal path; abort()'s best-effort finalize races it, documented). +double trimAutoTailInPlace(const std::string& path, + double rangeStartSeconds, + double rangeEndSeconds) { + constexpr double kNoTrim = -1.0; + + std::vector bytes = util::readFileBytes(path); + if (bytes.empty()) return kNoTrim; + + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim + + const std::size_t totalFrames = layout.frameCount(); + if (totalFrames == 0) return kNoTrim; + + // The original range end as a frame index within the file (frame 0 == start). Use + // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow + // project). Clamp to the file so a rounding overshoot cannot exceed it. + const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; + if (rangeSeconds <= 0.0) return kNoTrim; + std::size_t rangeEndFrame = static_cast( + rangeSeconds * static_cast(layout.sampleRate) + 0.5); + if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; + + // Nothing recorded past the range end (the tail window was empty) -> nothing to + // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) + if (rangeEndFrame >= totalFrames) return kNoTrim; + + // Scan ONLY the tail region (frames after the original range end). The trim never + // eats into the range body — the scan starts at rangeEndFrame. + const std::size_t tailFrames = totalFrames - rangeEndFrame; + const std::vector tailPcm = + extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); + if (tailPcm.empty()) return kNoTrim; + + const float threshold = static_cast(autoTrimEndRatio()); + const std::size_t lastAbove = audio::lastFrameAboveThreshold( + tailPcm, layout.channelCount, tailFrames, threshold); + + // keptFrames: the total frame count the trimmed file retains. + // no audible tail frame -> trim back to the range end (rangeEndFrame frames) + // an audible frame at idx -> keep range body + up to and including that frame + // The "signal never falls below threshold" case falls out naturally: lastAbove is + // the final tail frame, so keptFrames == totalFrames (the full window is kept). + std::size_t keptFrames; + if (lastAbove == audio::kNoFrameAboveThreshold) { + keptFrames = rangeEndFrame; + } else { + keptFrames = rangeEndFrame + (lastAbove + 1); + } + if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate + + const WavTruncatePlan plan = planWavTruncate(layout, keptFrames); + if (!plan.valid) return kNoTrim; + + // Patch the RIFF + data size fields in the in-memory buffer so they describe the + // kept frame count (wav_codec's patch primitive — the one RIFF owner), then + // rewrite the file as exactly the first newFileByteLength bytes (header + + // patched sizes + retained PCM). A single truncating write is the simplest + // correct truncate — no separate resize step, no partial-write window where the + // on-disk sizes and length disagree. The result is a valid, playable WAV of the + // kept frames (verified by the wav_codec re-parse test). + patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); + patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); + + // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full + // disk, a yanked drive) would leave a short file while we return kNoTrim, so the + // Sample length would overstate the file. Vanishingly unlikely for a just-recorded + // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- + // rename is not warranted here; flagged rather than built. + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file + out.write(reinterpret_cast(bytes.data()), + static_cast(plan.newFileByteLength)); + if (!out) return kNoTrim; + out.close(); + + // The trimmed length in seconds for the Sample metadata. + return static_cast(keptFrames) / static_cast(layout.sampleRate); +} + +} // namespace + +std::string recordedFilePath(MediaTrack* temp) { + if (!temp) return {}; + if (GetTrackNumMediaItems(temp) <= 0) return {}; + MediaItem* item = GetTrackMediaItem(temp, 0); + if (!item) return {}; + MediaItem_Take* take = GetMediaItemTake(item, 0); + if (!take) return {}; + PCM_source* src = GetMediaItemTake_Source(take); + if (!src) return {}; + std::vector buf(4096, '\0'); + GetMediaSourceFileName(src, buf.data(), static_cast(buf.size())); + return normSlashes(std::string(buf.data())); +} + +CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, + const CaptureRequest& request, + const BankPaths& paths, + const std::string& uniqueTag, + double recordWindowEnd) { + CaptureResult result; + + const std::string recorded = recordedFilePath(temp); + if (recorded.empty() || !std::filesystem::exists(recorded)) { + result.status = CaptureStatus::RenderFailed; + result.message = "Realtime record produced no file (check transport/record " + "settings in the DAW)."; + return result; + } + + std::error_code ec; + std::filesystem::create_directories(paths.absoluteDir, ec); + const std::string destPath = paths.absoluteDir + "/" + paths.fileName; + std::filesystem::rename(recorded, destPath, ec); + if (ec) { + // Cross-volume rename can fail; fall back to copy+remove. + ec.clear(); + std::filesystem::copy_file( + recorded, destPath, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) { + result.status = CaptureStatus::RenderFailed; + result.message = "Recorded file could not be moved into the bank: " + + ec.message(); + return result; + } + std::error_code rmEc; + std::filesystem::remove(recorded, rmEc); // best-effort + } + + // TAIL (Auto): trim the trailing decay of the recorded window in place — on the + // BANK file we now own (destPath), never the project. Best-effort: an unreadable / + // unknown-format / short file skips the trim (keeps the full window) rather than + // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a + // fixed window (spec §The realtime path). Returns the trimmed length in seconds, + // or < 0 for "no trim applied". + double trimmedLenSeconds = -1.0; + if (request.tailMode == TailMode::Auto) { + trimmedLenSeconds = trimAutoTailInPlace(destPath, + request.startSeconds, + request.endSeconds); + } + + // The pure recorded-capture -> Sample mapping (identity, bounds echo, tier). + RecordedCapture cap; + cap.relativePath = paths.relativePath; + cap.uniqueTag = uniqueTag; + cap.sourceMode = SourceMode::Realtime; + cap.startSeconds = request.startSeconds; + cap.endSeconds = request.endSeconds; + cap.wetDry = request.wetDry; + cap.displayName = request.baseName; + cap.trackGuids = request.trackGuids; + cap.channelCount = request.channelCount; + + result.status = CaptureStatus::Ok; + result.sample = sampleFromRecordedCapture(cap); + + // The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount + // (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read + // against the record's OWN project), captureTempo, the capture-start time + // signature (timeSigProj = proj: the realtime path PINS the record's own + // project — the divergence from offline's active-project read, kept + // caller-visible here), the WAV-aware contentHash of the (possibly trimmed) + // bank file, and createdTimestamp. + stampCaptureSample(result.sample, request, /*rateProj=*/proj, + /*timeSigProj=*/proj, destPath); + + // The recorded file's true length differs from the request range when a tail was + // recorded, so the Sample length must reflect the FILE, not the range: + // Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. + // Auto with no trim, or Manual -> the full recorded window (end - start). + // None -> the exact range (unchanged; recordWindowEnd == endSeconds). + // sampleFromRecordedCapture already set lengthSeconds = end - start; override it + // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk. + if (trimmedLenSeconds >= 0.0) { + result.sample.lengthSeconds = trimmedLenSeconds; + } else { + result.sample.lengthSeconds = recordWindowEnd - request.startSeconds; + } + + result.message = "Realtime-captured [" + + std::to_string(request.startSeconds) + "s, " + + std::to_string(request.endSeconds) + "s] (recorded " + + std::to_string(result.sample.lengthSeconds) + "s) -> " + + paths.relativePath; + return result; +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime_finalize.h b/src/shell/capture/capture_realtime_finalize.h new file mode 100644 index 0000000..e1a900f --- /dev/null +++ b/src/shell/capture/capture_realtime_finalize.h @@ -0,0 +1,42 @@ +#pragma once +// capture_realtime_finalize — the FILE-SIDE half of the realtime-record shell +// (Q-W3, T4-08 split riding the Q-9 rename): discovering the file REAPER actually +// recorded, moving it into the bank, the Auto-tail PCM decay-scan trim, and the +// finished-Sample population. The async record LIFECYCLE (state snapshot/restore, +// begin/tick/abort) lives in capture_realtime_shell.cpp; this half talks to +// wav_codec and the filesystem, not to the transport. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). +// MediaTrack / ReaProject are forward-declared (via capture.h) so this header +// stays SDK-lite. + +#include + +#include "shell/capture/capture.h" // CaptureRequest / CaptureResult +#include "core/capture/capture_paths.h" // BankPaths + +namespace reasampler::capture { + +// Discovers the file REAPER actually recorded onto the temp track: the first media +// item's active take's source file, forward-slashed. Empty string if nothing was +// recorded (no item / take / source). Also used by the lifecycle's flush wait +// (size-stable check) before finalize runs. +std::string recordedFilePath(MediaTrack* temp); + +// Builds a CaptureResult for a finalized recording: discover the recorded file, +// move it into the bank at `paths`, Auto-trim the tail decay in place when the +// request asks for it, and populate the Sample (pure sampleFromRecordedCapture + +// the shared stampCaptureSample — both project reads pinned to `proj`, the +// record's OWN project). Returns Ok + Sample on success, or a RenderFailed result. +// Does NOT restore any snapshotted state — the caller restores unconditionally +// afterward (finalize + restore are separate steps so a finalize failure still +// restores). `recordWindowEnd` is the recorded window end in project seconds +// (>= request.endSeconds when a tail was recorded) — the untrimmed-length source. +CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, + const CaptureRequest& request, + const BankPaths& paths, + const std::string& uniqueTag, + double recordWindowEnd); + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime.cpp b/src/shell/capture/capture_realtime_shell.cpp similarity index 67% rename from src/shell/capture/capture_realtime.cpp rename to src/shell/capture/capture_realtime_shell.cpp index 21c0612..981e76d 100644 --- a/src/shell/capture/capture_realtime.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -1,5 +1,10 @@ -#include "core/namespaces.h" -// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend). +// capture_realtime_shell.cpp — REAPER-facing realtime-record backend +// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore + +// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming +// rider: the PURE module owns the capture_realtime stem, this shell takes the +// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file +// discovery, move-into-bank, Auto-tail trim, Sample population — lives in +// capture_realtime_finalize.cpp (T4-08 split). // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API @@ -30,8 +35,9 @@ // completion, user stop, error, second-capture reject, project switch, unload — // funnels through the SAME single restore, safe to call once from whichever fires. // The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the -// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp} -// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe. +// completion state machine (advanceRecordPhase) all live in the pure +// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU +// owns only the REAPER-bound lifecycle recipe. // // ============================================================================ // §TAP — track-output tap (selected track's own output, PRE-parent) @@ -72,26 +78,18 @@ #include #include -#include -#include #include -#include #include #include -#include "core/capture/capture_paths.h" // hashBytes, deriveBankPaths -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "core/audio/peaks.h" // lastFrameAboveThreshold, AudioSample -#include "core/capture/realtime_record.h" -#include "core/capture/render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate +#include "core/capture/capture_paths.h" // deriveBankPaths +#include "core/capture/capture_realtime.h" // RecordPhase machine, record-mode plan (pure) +#include "core/capture/render_settings.h" // realtimeRecordWindowEnd +#include "shell/capture/capture_realtime_finalize.h" // recordedFilePath, finalizeRecording #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_Main_SaveProject -#define REAPERAPI_WANT_Master_GetTempo -#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime -#define REAPERAPI_WANT_GetSetProjectInfo #define REAPERAPI_WANT_InsertTrackAtIndex #define REAPERAPI_WANT_DeleteTrack #define REAPERAPI_WANT_CountTracks @@ -99,11 +97,6 @@ #define REAPERAPI_WANT_CreateTrackSend #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value -#define REAPERAPI_WANT_GetTrackNumMediaItems -#define REAPERAPI_WANT_GetTrackMediaItem -#define REAPERAPI_WANT_GetMediaItemTake -#define REAPERAPI_WANT_GetMediaItemTake_Source -#define REAPERAPI_WANT_GetMediaSourceFileName #define REAPERAPI_WANT_CSurf_OnRecord #define REAPERAPI_WANT_OnStopButtonEx #define REAPERAPI_WANT_GetPlayStateEx @@ -114,16 +107,10 @@ #define REAPERAPI_WANT_ValidatePtr2 #include "reaper_plugin_functions.h" -namespace reasampler { +namespace reasampler::capture { namespace { -// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide. -std::string makeUniqueTag() { - std::time_t now = std::time(nullptr); - return "rt-" + std::to_string(static_cast(now)); -} - std::string normSlashes(std::string s) { for (char& c : s) if (c == '\\') c = '/'; if (s.size() > 1 && s.back() == '/') s.pop_back(); @@ -138,22 +125,6 @@ std::string readRppPath() { return std::string(buf.data()); } -// Discovers the file REAPER actually recorded onto the temp track: the first media -// item's active take's source file. Empty string if nothing was recorded. -std::string recordedFilePath(MediaTrack* temp) { - if (!temp) return {}; - if (GetTrackNumMediaItems(temp) <= 0) return {}; - MediaItem* item = GetTrackMediaItem(temp, 0); - if (!item) return {}; - MediaItem_Take* take = GetMediaItemTake(item, 0); - if (!take) return {}; - PCM_source* src = GetMediaItemTake_Source(take); - if (!src) return {}; - std::vector buf(4096, '\0'); - GetMediaSourceFileName(src, buf.data(), static_cast(buf.size())); - return std::string(buf.data()); -} - // The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no // item/take/source, or the file does not exist on disk this tick). Used by the flush // wait to detect stability (size unchanged across a tick) BEFORE moving the file — a @@ -323,236 +294,9 @@ private: bool finalized_ = false; }; -namespace { - -// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): -// empty on any I/O failure — the caller treats an unreadable file as "skip the -// trim" (keep the untrimmed window), never as a corruption of the recorded audio. - -// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). -void writeU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { - bytes[off + 0] = static_cast(v & 0xFF); - bytes[off + 1] = static_cast((v >> 8) & 0xFF); - bytes[off + 2] = static_cast((v >> 16) & 0xFF); - bytes[off + 3] = static_cast((v >> 24) & 0xFF); -} - -// ============================================================================ -// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) -// ============================================================================ -// After the recorded file is stable and moved into the bank (the file we OWN — never -// the project), Auto mode trims the trailing decay: read the WAV, scan the tail -// region (frames AFTER the original range end) backward for the last frame above -// -72 dB, and truncate the file there. Rules (spec): -// * no frame in the tail window above -72 dB -> trim back to the original range end -// * signal never falls below -72 dB in window -> keep the full window (cap did its job) -// * otherwise -> trim one frame past the last audible -// -// Returns the trimmed length in SECONDS (for the Sample), or a negative value to -// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and -// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) -// rather than risk corrupting the capture — realtime tail is a convenience path. -// -// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit -// float WAV (REAPER project record format — the manual procedure sets it) and is fully -// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees -// that for the normal path; abort()'s best-effort finalize races it, documented). -double trimAutoTailInPlace(const std::string& path, - double rangeStartSeconds, - double rangeEndSeconds) { - constexpr double kNoTrim = -1.0; - - std::vector bytes = readFileBytes(path); - if (bytes.empty()) return kNoTrim; - - const reasampler::WavLayout layout = parseWavLayout(bytes); - if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim - - const std::size_t totalFrames = layout.frameCount(); - if (totalFrames == 0) return kNoTrim; - - // The original range end as a frame index within the file (frame 0 == start). Use - // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow - // project). Clamp to the file so a rounding overshoot cannot exceed it. - const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; - if (rangeSeconds <= 0.0) return kNoTrim; - std::size_t rangeEndFrame = static_cast( - rangeSeconds * static_cast(layout.sampleRate) + 0.5); - if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; - - // Nothing recorded past the range end (the tail window was empty) -> nothing to - // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) - if (rangeEndFrame >= totalFrames) return kNoTrim; - - // Scan ONLY the tail region (frames after the original range end). The trim never - // eats into the range body — the scan starts at rangeEndFrame. - const std::size_t tailFrames = totalFrames - rangeEndFrame; - const std::vector tailPcm = - extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); - if (tailPcm.empty()) return kNoTrim; - - const float threshold = static_cast(reasampler::autoTrimEndRatio()); - const std::size_t lastAbove = reasampler::lastFrameAboveThreshold( - tailPcm, layout.channelCount, tailFrames, threshold); - - // keptFrames: the total frame count the trimmed file retains. - // no audible tail frame -> trim back to the range end (rangeEndFrame frames) - // an audible frame at idx -> keep range body + up to and including that frame - // The "signal never falls below threshold" case falls out naturally: lastAbove is - // the final tail frame, so keptFrames == totalFrames (the full window is kept). - std::size_t keptFrames; - if (lastAbove == reasampler::kNoFrameAboveThreshold) { - keptFrames = rangeEndFrame; - } else { - keptFrames = rangeEndFrame + (lastAbove + 1); - } - if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate - - const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames); - if (!plan.valid) return kNoTrim; - - // Patch the RIFF + data size fields in the in-memory buffer so they describe the - // kept frame count, then rewrite the file as exactly the first newFileByteLength - // bytes (header + patched sizes + retained PCM). A single truncating write is the - // simplest correct truncate — no separate resize step, no partial-write window - // where the on-disk sizes and length disagree. The result is a valid, playable WAV - // of the kept frames (verified by the wav_trim re-parse test). - writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); - writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); - - // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full - // disk, a yanked drive) would leave a short file while we return kNoTrim, so the - // Sample length would overstate the file. Vanishingly unlikely for a just-recorded - // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- - // rename is not warranted here; flagged rather than built. - std::ofstream out(path, std::ios::binary | std::ios::trunc); - if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file - out.write(reinterpret_cast(bytes.data()), - static_cast(plan.newFileByteLength)); - if (!out) return kNoTrim; - out.close(); - - // The trimmed length in seconds for the Sample metadata. - return static_cast(keptFrames) / static_cast(layout.sampleRate); -} - -// Builds a CaptureResult for a finalized recording: discover the recorded file, -// move it into the bank, populate the Sample via the pure mapping. Returns Ok + -// Sample on success, or a RenderFailed result. Does NOT restore — the caller -// restores unconditionally afterward (finalize + restore are separate steps so a -// finalize failure still restores). -CaptureResult finalizeRecording(RealtimeCaptureState& st) { - CaptureResult result; - - const std::string recorded = normSlashes(recordedFilePath(st.temp_)); - if (recorded.empty() || !std::filesystem::exists(recorded)) { - result.status = CaptureStatus::RenderFailed; - result.message = "Realtime record produced no file (check transport/record " - "settings in the DAW)."; - return result; - } - - std::error_code ec; - std::filesystem::create_directories(st.paths_.absoluteDir, ec); - const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName; - std::filesystem::rename(recorded, destPath, ec); - if (ec) { - // Cross-volume rename can fail; fall back to copy+remove. - ec.clear(); - std::filesystem::copy_file( - recorded, destPath, - std::filesystem::copy_options::overwrite_existing, ec); - if (ec) { - result.status = CaptureStatus::RenderFailed; - result.message = "Recorded file could not be moved into the bank: " + - ec.message(); - return result; - } - std::error_code rmEc; - std::filesystem::remove(recorded, rmEc); // best-effort - } - - // TAIL (Auto): trim the trailing decay of the recorded window in place — on the - // BANK file we now own (destPath), never the project. Best-effort: an unreadable / - // unknown-format / short file skips the trim (keeps the full window) rather than - // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a - // fixed window (spec §The realtime path). Returns the trimmed length in seconds, - // or < 0 for "no trim applied". - double trimmedLenSeconds = -1.0; - if (st.request_.tailMode == TailMode::Auto) { - trimmedLenSeconds = trimAutoTailInPlace(destPath, - st.request_.startSeconds, - st.request_.endSeconds); - } - - RecordedCapture cap; - cap.relativePath = st.paths_.relativePath; - cap.uniqueTag = st.uniqueTag_; - cap.sourceMode = SourceMode::Realtime; - cap.startSeconds = st.request_.startSeconds; - cap.endSeconds = st.request_.endSeconds; - cap.wetDry = st.request_.wetDry; - cap.displayName = st.request_.baseName; - cap.trackGuids = st.request_.trackGuids; - cap.channelCount = st.request_.channelCount; - cap.sampleRate = (st.request_.sampleRate > 0) - ? st.request_.sampleRate - : static_cast(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false)); - cap.captureTempo = Master_GetTempo(); - // Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime - // (reaper_plugin_functions.h:7130) reads the meter effective at that project time; - // proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is - // the master tempo above). Leaves 0/0 (unstamped) on any failure. - { - int tsNum = 0, tsDenom = 0; - double tsTempo = 0.0; - TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo); - cap.captureTimeSigNum = tsNum; - cap.captureTimeSigDenom = tsDenom; - } - cap.createdTimestamp = static_cast(std::time(nullptr)); - - result.status = CaptureStatus::Ok; - result.sample = sampleFromRecordedCapture(cap); - - // Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data - // chunks so hashReferencedElsewhere can identify copies in other banks and suppress - // the last-reference confirm when another bank still holds the same file. Using - // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks - // (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical - // audio collapse to the same hash. Best-effort: an unreadable file leaves - // contentHash empty — the safe, confirm-eliciting direction (bank_model treats - // "" as non-participating). - { - const std::vector fileBytes = readFileBytes(destPath); - if (!fileBytes.empty()) { - result.sample.contentHash = hashWavContent(fileBytes); - } - } - - // The recorded file's true length differs from the request range when a tail was - // recorded, so the Sample length must reflect the FILE, not the range: - // Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. - // Auto with no trim, or Manual -> the full recorded window (end - start). - // None -> the exact range (unchanged; recordWindowEnd_ == endSeconds). - // sampleFromRecordedCapture already set lengthSeconds = end - start; override it - // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk. - if (trimmedLenSeconds >= 0.0) { - result.sample.lengthSeconds = trimmedLenSeconds; - } else { - result.sample.lengthSeconds = - st.recordWindowEnd_ - st.request_.startSeconds; - } - - result.message = "Realtime-captured [" + - std::to_string(st.request_.startSeconds) + "s, " + - std::to_string(st.request_.endSeconds) + "s] (recorded " + - std::to_string(result.sample.lengthSeconds) + "s) -> " + - st.paths_.relativePath; - return result; -} - -} // namespace +// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the +// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in +// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle. // ============================================================================ // begin — start the record, snapshot, return immediately (no UI block) @@ -625,7 +369,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, RealtimeCaptureHandle st(new RealtimeCaptureState()); st->proj_ = proj; st->request_ = request; - st->uniqueTag_ = makeUniqueTag(); + st->uniqueTag_ = makeUniqueTag("rt-"); // shared mint (T1-11 monotonic counter) st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_); // The recorded window end: extended past the range end for a tail mode (Auto/Manual), @@ -788,7 +532,9 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { // ALL snapshotted state — the non-destructive gate, idempotent + unconditional. CaptureResult res; if (state.phase_ == RecordPhase::Done) { - res = finalizeRecording(state); + res = finalizeRecording(state.proj_, state.temp_, state.request_, + state.paths_, state.uniqueTag_, + state.recordWindowEnd_); } else { res.status = CaptureStatus::RenderFailed; res.message = "Realtime record timed out waiting for the recorded file to " @@ -844,7 +590,9 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { // is the one that must be flush-safe. state.stopOwnTransport(); - CaptureResult res = finalizeRecording(state); + CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_, + state.paths_, state.uniqueTag_, + state.recordWindowEnd_); state.markFinalized(); state.restore(); // the non-destructive gate — always runs @@ -855,4 +603,4 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { return out; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/shell/capture/realtime_lifecycle.cpp b/src/shell/capture/realtime_lifecycle.cpp new file mode 100644 index 0000000..275694c --- /dev/null +++ b/src/shell/capture/realtime_lifecycle.cpp @@ -0,0 +1,101 @@ +// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals +// (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded as a +// parameter). See the header. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/realtime_lifecycle.h" + +#include "persist.h" // ReaSamplerSession + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_ShowConsoleMsg +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +// --- M8 in-flight realtime capture (async, timer-driven) -------------------- +RealtimeRecordBackend g_rtBackend; +RealtimeCaptureHandle g_rtCapture; +ReaProject* g_rtCaptureProject = nullptr; + +// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the +// Sample to the ACTIVE bank (session.bank() resolves to book.activeIndex() — B2), +// persist + MarkProjectDirty. Shared by the tick-completion path and the abort +// paths. On a non-Ok result, logs the failure only. +void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res) +{ + if (res.status != CaptureStatus::Ok) + { + ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); + return; + } + session.bank().add(res.sample); + // B-cap: record the file the capture created in the owned-file manifest, at the same + // point the Sample is added and before the same persist. Recorded regardless of the + // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the + // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). + session.owned().add(res.sample.relativePath); + // S9: a capture add changes what a live instance could play (a new sample landed in the + // active bank) -> bump before the persist so the stamped generation refreshes instances. + session.bumpBankGeneration(); + session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) +} + +// Advance any in-flight realtime capture one tick. Cheap when none is running (a +// null check) and fast even mid-record (tick() only reads the transport until the +// terminal tick). Detects a project switch mid-capture and aborts+restores so the +// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so +// poll's project-switch handling sees a cleaned-up project. +void DriveRealtimeCapture(ReaSamplerSession& session) +{ + if (!g_rtCapture) return; + + // Project switch guard: if the active project is no longer the one the capture + // belongs to, a new/other project became active mid-record — abort + restore + // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize + // into the new project. + ReaProject* active = EnumProjects(-1, nullptr, 0); + if (active != g_rtCaptureProject) + { + RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); + // Only commit if the ORIGINAL project is still open and active would be it — + // on a switch we restored into the original but must not persist into the + // now-active foreign project. Log the outcome without persisting. On a Failed + // abort surface abort()'s own message — it distinguishes a clean tab-switch + // abort from the closed-project DROP (the captured project was closed mid-record, + // review §1: nothing restored because the pointers were already freed). + if (r.status == RealtimeTickStatus::Done) + ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " + "captured audio restored into the original project; not " + "persisted to avoid crossing projects.\n"); + else + ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " + + r.result.message + "\n").c_str()); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; + return; + } + + RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture); + if (r.status == RealtimeTickStatus::InProgress) return; + + // Terminal (Done or Failed): commit/log and drop the in-flight state. + CommitRealtimeResult(session, r.result); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; +} + +void AbortRealtimeCaptureForUnload(ReaSamplerSession& session) +{ + if (!g_rtCapture) return; + RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); + CommitRealtimeResult(session, r.result); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/realtime_lifecycle.h b/src/shell/capture/realtime_lifecycle.h new file mode 100644 index 0000000..ac71be5 --- /dev/null +++ b/src/shell/capture/realtime_lifecycle.h @@ -0,0 +1,56 @@ +#pragma once +// realtime_lifecycle — the in-flight realtime-capture state machine + globals +// (Q-W3 hoist out of main.cpp). A realtime record spans many timer ticks (it takes +// end-start wall-clock seconds and must NOT block REAPER's UI): the action STARTS +// it (capture_orchestrator::RunCaptureRealtimeTrack -> g_rtBackend.begin), OnTimer +// drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a +// terminal verdict, then the handle is cleared. +// +// The three globals are EXPOSED (extern) rather than wrapped: the action bodies in +// capture_orchestrator manipulate them exactly as main.cpp did (zero-behavior-change +// move), and — load-bearing (CONTEXT.md §Phase Q hot-path guardrail) — the timer's +// IDLE FAST-PATH stays a SINGLE POINTER TEST at the call site: +// if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); +// No per-tick cross-TU call, no accessor indirection, when nothing is recording. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include "shell/capture/capture.h" // RealtimeRecordBackend / RealtimeCaptureHandle + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +// The realtime backend + the in-flight capture handle. Non-null handle == a +// capture is in progress (used to reject a second one, to drive the per-tick +// advance, and to abort on project switch / unload). +extern RealtimeRecordBackend g_rtBackend; +extern RealtimeCaptureHandle g_rtCapture; + +// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets +// OnTimer detect a project switch mid-capture and abort+restore rather than leak the +// temp track/arm/transport into or across projects. Only meaningful when +// g_rtCapture != nullptr. +extern ReaProject* g_rtCaptureProject; + +// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the +// Sample to the ACTIVE bank, record the owned file, bump the generation, persist + +// MarkProjectDirty. On a non-Ok result, logs the failure only. +void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res); + +// Advance any in-flight realtime capture one tick. Cheap when none is running (a +// null check — though the caller already guards, see the header note) and fast even +// mid-record. Detects a project switch mid-capture and aborts+restores so the +// capture never leaks across projects. Called from OnTimer BEFORE session.poll(). +void DriveRealtimeCapture(ReaSamplerSession& session); + +// Unload teardown: abort any in-flight capture while the API pointers are still +// live — finalize-or-abort + restore so we never leave a temp track, an armed +// track, or an altered transport/cursor in the user's project on unload. Commits +// whatever was captured (best effort) before tearing down. No-op when idle. +void AbortRealtimeCaptureForUnload(ReaSamplerSession& session); + +} // namespace reasampler::capture diff --git a/src/shell/capture/scope_resolve.cpp b/src/shell/capture/scope_resolve.cpp new file mode 100644 index 0000000..6caabe5 --- /dev/null +++ b/src/shell/capture/scope_resolve.cpp @@ -0,0 +1,242 @@ +// scope_resolve.cpp — scope/source resolution for the capture action family +// (Q-W3 hoist out of main.cpp; the code moved verbatim, session state threaded as +// parameters). See the header. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/scope_resolve.h" + +#include // project-dir derivation for provenance parent resolution +#include + +#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs +#include "shell/capture/track_guid.h" // guidString + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetSet_LoopTimeRange +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItem_Track +#define REAPERAPI_WANT_CountSelectedTracks +#define REAPERAPI_WANT_GetSelectedTrack +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, +// isLoop=false) reads the current time selection. +bool resolveTimeSelection(double& start, double& end) +{ + start = 0.0; end = 0.0; + GetSet_LoopTimeRange(false, false, &start, &end, false); + return end > start; +} + +// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the +// pure provenance module does not depend on render_settings). +model::ProvenanceScope provenanceScopeFor(CaptureScope scope) +{ + return scope == CaptureScope::Item ? model::ProvenanceScope::Item + : model::ProvenanceScope::Track; +} + +// Collects the tracks that own the selected items (Item scope) into +// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an +// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning +// track (SDK header, verify). GUIDs recorded for provenance. +bool collectSelectedItemTracks(ResolvedSource& out) +{ + const int n = CountSelectedMediaItems(nullptr); + if (n <= 0) return false; + for (int i = 0; i < n; ++i) + { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + MediaTrack* tr = GetMediaItem_Track(it); + if (!tr) continue; + // Dedup: several selected items can share a track. + bool seen = false; + for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; } + if (seen) continue; + out.sourceTracks.push_back(tr); + std::string g = guidString(tr); + if (!g.empty()) out.trackGuids.push_back(std::move(g)); + } + return !out.sourceTracks.empty(); +} + +} // namespace + +// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of +// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), +// and returns the union bound. Reads only — never clears the razor selection. +// Returns false when no track-audio razor area exists on any track. +bool resolveRazorRange(double& start, double& end) +{ + std::vector allRanges; + const int n = CountTracks(nullptr); + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetTrack(nullptr, i); + if (!tr) continue; + std::vector buf(8192, '\0'); + if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) + continue; + std::vector ranges = parseRazorEdits(std::string(buf.data())); + for (auto& r : ranges) allRanges.push_back(r); + } + if (allRanges.empty()) return false; + RazorRange u = razorUnionBounds(allRanges); + start = u.startSeconds; + end = u.endSeconds; + return end > start; +} + +// Infers the render RANGE for any scope: razor union when a razor area is present, +// else the time selection (pure inferRangeSource decides which). Orthogonal to +// scope. Returns false (with a reason) when neither yields a non-empty range. +bool resolveRange(double& start, double& end, std::string& why) +{ + double rzStart = 0.0, rzEnd = 0.0; + const bool hasRazor = resolveRazorRange(rzStart, rzEnd); + if (inferRangeSource(hasRazor) == RangeSource::Razor) + { + start = rzStart; end = rzEnd; + return true; // resolveRazorRange already verified end > start + } + if (resolveTimeSelection(start, end)) return true; + why = "make a razor area or a time selection first"; + return false; +} + +// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. +bool collectSelectedTracks(ResolvedSource& out) +{ + const int n = CountSelectedTracks(nullptr); // nullptr = active project + if (n <= 0) return false; + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetSelectedTrack(nullptr, i); + if (!tr) continue; + out.sourceTracks.push_back(tr); + std::string g = guidString(tr); + if (!g.empty()) out.trackGuids.push_back(std::move(g)); + } + return !out.sourceTracks.empty(); +} + +// Resolves the source for a scope: the selection tracks (item/track), plus the +// inferred range. Returns false with a reason on nothing to do. +bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why) +{ + switch (scope) + { + case CaptureScope::Item: + if (!collectSelectedItemTracks(out)) { + why = "select at least one media item"; return false; + } + break; + case CaptureScope::Track: + if (!collectSelectedTracks(out)) { + why = "select at least one track"; return false; + } + break; + } + return resolveRange(out.startSeconds, out.endSeconds, why); +} + +// Current project's directory (parent of its .rpp), forward-slashed, no trailing +// slash — the same derivation capture.cpp does internally, needed here so M10 can +// resolve the bank's relative paths to absolute for parent detection. Empty for an +// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank +// file resolve empty -> no false parentage. Read-only; mutates nothing. +std::string currentProjectDir() +{ + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + const std::string rpp(buf.data()); + if (rpp.empty()) return {}; + namespace fs = std::filesystem; + std::string dir = fs::path(rpp).parent_path().string(); + for (char& c : dir) if (c == '\\') c = '/'; + if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); + return dir; +} + +// Builds the M10 provenance for a capture IF it genuinely resamples from a bank +// sample, else returns nullopt (the common, non-resample case). Detection rule +// (stated honestly): the capture's source item media file(s) must all resolve, by +// exact normalized absolute path, to ONE bank sample's file (detectParent). On a +// match, records that sample's id as the parent plus a THIN capture-recipe +// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels + +// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from +// source" can replay the request and report drift. NEVER a serialized chain to +// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per +// selected item, combined in item order; Track scope reads the track FX chain. +std::optional buildCaptureProvenance( + const BankBook& book, const CaptureRequest& req, + CaptureScope scope, const ResolvedSource& src) +{ + const std::string projectDir = currentProjectDir(); + const std::vector bankFiles = bankFileRefs(book, projectDir); + + // The "what audio is being captured" source set depends on scope: item scope uses + // the SELECTED items (the user picked them); track scope uses the range-overlapping + // items ON the source tracks (the user picked the track, not the item). + const std::vector sourceFiles = + scope == CaptureScope::Item + ? selectedItemSourceFiles() + : trackItemSourceFiles(src.sourceTracks, req.startSeconds, + req.endSeconds); + + const std::optional parentId = + model::detectParent(sourceFiles, bankFiles); + if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance + + model::CaptureRecipe recipe; + recipe.scope = provenanceScopeFor(scope); + recipe.sourceMode = static_cast(req.sourceMode); + recipe.startSeconds = req.startSeconds; + recipe.endSeconds = req.endSeconds; + recipe.tailMode = static_cast(req.tailMode); + recipe.tailMs = req.tailMs; + recipe.sampleRate = req.sampleRate; + recipe.channelCount = req.channelCount; + recipe.trackGuids = req.trackGuids; + // The in-scope FX-chain identity: + // Track scope — per-track chains combined in track order (TrackFX_*). + // Item scope — per-item active-take chains combined in item order (TakeFX_*); + // the owning track's FX chain is OUT OF SCOPE for an item capture and must + // not be fingerprinted here (it is bypassed during render, not heard). + if (scope == CaptureScope::Item) { + const int n = CountSelectedMediaItems(nullptr); + std::vector items; + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (it) items.push_back(it); + } + recipe.fxChainIdentity = fxChainIdentityForItems(items); + } else { + std::vector perTrack; + perTrack.reserve(src.sourceTracks.size()); + for (MediaTrack* tr : src.sourceTracks) + perTrack.push_back(fxChainIdentityForTrack(tr)); + recipe.fxChainIdentity = model::combineChainIdentities(perTrack); + } + + model::Provenance prov; + prov.parentSampleId = *parentId; + prov.fxChainSnapshot = model::buildFingerprint(recipe); + return prov; +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/scope_resolve.h b/src/shell/capture/scope_resolve.h new file mode 100644 index 0000000..991b614 --- /dev/null +++ b/src/shell/capture/scope_resolve.h @@ -0,0 +1,71 @@ +#pragma once +// scope_resolve — scope/source resolution for the capture action family (Q-W3 +// hoist out of main.cpp). The three concerns every capture entry point shares: +// * RANGE inference — razor union else time selection (razor-else-time), +// orthogonal to scope; +// * SOURCE-TRACK collection — the selected tracks (Track scope) or the selected +// items' owning tracks (Item scope), deduped, with canonical GUIDs; +// * PROVENANCE ASSEMBLY inputs — the M10 resample-from-sample detection + the +// thin capture-recipe fingerprint built from the LIVE (un-bypassed) chain. +// +// All reads are non-destructive: selection, razor, and time selection are read, +// never mutated. REAPER-facing: the .cpp includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md +// §contract). MediaTrack is forward-declared (via capture.h) so this header stays +// SDK-lite. + +#include +#include +#include + +#include "shell/capture/capture.h" // CaptureRequest, MediaTrack fwd +#include "core/capture/render_settings.h" // CaptureScope +#include "core/model/provenance.h" // model::Provenance + +namespace reasampler { +class BankBook; +} + +namespace reasampler::capture { + +// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample +// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the +// selected tracks (Track scope). +struct ResolvedSource +{ + double startSeconds = 0.0; + double endSeconds = 0.0; + std::vector sourceTracks; // item-owning tracks / selected tracks + std::vector trackGuids; // canonical GUIDs of sourceTracks +}; + +// Reads every track's P_RAZOREDITS, parses the track-audio areas (pure +// parseRazorEdits), and returns the union bound. Reads only — never clears the +// razor selection. Returns false when no track-audio razor area exists on any track. +bool resolveRazorRange(double& start, double& end); + +// Infers the render RANGE for any scope: razor union when a razor area is present, +// else the time selection (pure inferRangeSource decides which). Orthogonal to +// scope. Returns false (with a reason) when neither yields a non-empty range. +bool resolveRange(double& start, double& end, std::string& why); + +// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. +bool collectSelectedTracks(ResolvedSource& out); + +// Resolves the source for a scope: the selection tracks (item/track), plus the +// inferred range. Returns false with a reason on nothing to do. +bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why); + +// Current project's directory (parent of its .rpp), forward-slashed, no trailing +// slash. Empty for an unsaved project (no false parentage). Read-only. +std::string currentProjectDir(); + +// Builds the M10 provenance for a capture IF it genuinely resamples from a bank +// sample (detectParent over `book`'s resolved file refs), else returns nullopt (the +// common, non-resample case). Must run BEFORE the FxBypassGuard neutralizes the +// in-scope chain — the source FX-chain identity is read from the LIVE chain. +std::optional buildCaptureProvenance( + const BankBook& book, const CaptureRequest& req, + CaptureScope scope, const ResolvedSource& src); + +} // namespace reasampler::capture diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 3d253c9..3b1b95c 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -5,11 +5,8 @@ #include "../src/core/capture/capture_paths.h" -#include #include -#include // std::memcpy (for putF32cp in hashWavContent tests) #include -#include using namespace reasampler; using namespace reasampler::capture; @@ -345,208 +342,9 @@ static void testTransitionInPlaceSaveIsNoOp() { == ProjectTransition::NoOp); } -// --- hashBytes (FNV-1a content hash) ---------------------------------------- -// -// The fix for the confirm-on-last-reference bug: hashBytes produces a 16-char hex -// string that capture.cpp and capture_realtime.cpp store on Sample::contentHash so -// BankBook::hashReferencedElsewhere can detect copies and suppress the confirm when -// another bank still holds the same file. - -static void testHashBytesOutputFormat() { - // Output is always 16 lowercase hex characters. - const std::uint8_t bytes[] = {0x01, 0x02, 0x03}; - const std::string h = hashBytes(bytes, 3); - CHECK(h.size() == 16); - for (char c : h) { - CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); - } -} - -static void testHashBytesDeterministic() { - // Same input always produces the same output (bit-identical captures get - // the same hash, so hashReferencedElsewhere fires correctly for copies). - const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01}; - CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5)); -} - -static void testHashBytesDistinct() { - // Different inputs produce different hashes (no accidental dedup of distinct - // files). This covers the "one-bit-flip changes the hash" property. - std::uint8_t a[] = {0x00, 0x00}; - std::uint8_t b[] = {0x00, 0x01}; - CHECK(hashBytes(a, 2) != hashBytes(b, 2)); - - std::uint8_t c[] = {0xFF, 0xFF, 0xFF}; - std::uint8_t d[] = {0xFF, 0xFF, 0xFE}; - CHECK(hashBytes(c, 3) != hashBytes(d, 3)); -} - -static void testHashBytesEmptyBufferIsNonEmpty() { - // An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty - // sentinel) — capturing the contract that even empty inputs yield a 16-char hash. - const std::string h = hashBytes(nullptr, 0); - CHECK(h.size() == 16); -} - -static void testHashBytesLargerBufferDiffersFromSmaller() { - // Padding a buffer with a zero byte must change the hash (order + length - // sensitivity so two differently-sized WAV files don't accidentally collide). - const std::uint8_t short_buf[] = {0xAB, 0xCD}; - const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00}; - CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3)); -} - -// --- hashWavContent (WAV-aware dedup hash) ----------------------------------- -// -// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata -// chunks like bext/LIST), falls back gracefully for non-WAV input, and that -// different audio data yields different hashes. - -// Minimal synthetic WAV builder (mirrors the one in test_wav_trim.cpp). -static void putU16cp(std::vector& b, std::uint16_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast((v >> 8) & 0xFF)); -} -static void putU32cp(std::vector& b, std::uint32_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast((v >> 8) & 0xFF)); - b.push_back(static_cast((v >> 16) & 0xFF)); - b.push_back(static_cast((v >> 24) & 0xFF)); -} -static void putTagcp(std::vector& b, const char* t) { - for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); -} -static void putF32cp(std::vector& b, float f) { - std::uint8_t tmp[4]; - std::memcpy(tmp, &f, 4); - for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); -} - -// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk -// inserted between "WAVE" and the fmt chunk. `metaChunkBody` and `metaTag` are -// used when `insertMeta` is true. This is the shape REAPER produces: a `bext` -// or `LIST` chunk before fmt with a render-time timestamp in the body. -static std::vector buildTestWav( - std::uint16_t channels, std::uint32_t sampleRate, - const std::vector& samples, - bool insertMeta = false, - const char* metaTag = "bext", - const std::vector& metaBody = {}) { - - std::vector chunks; - - if (insertMeta && !metaBody.empty()) { - putTagcp(chunks, metaTag); - putU32cp(chunks, static_cast(metaBody.size())); - chunks.insert(chunks.end(), metaBody.begin(), metaBody.end()); - if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad - } - - // fmt chunk (16-byte body, IEEE-float tag 3). - const std::uint32_t dataBytes = - static_cast(samples.size() * 4u); - putTagcp(chunks, "fmt "); - putU32cp(chunks, 16); - putU16cp(chunks, 3); // IEEE float - putU16cp(chunks, channels); - putU32cp(chunks, sampleRate); - putU32cp(chunks, sampleRate * channels * 4u); // byteRate - putU16cp(chunks, static_cast(channels * 4)); // blockAlign - putU16cp(chunks, 32); // bitsPerSample - - // data chunk. - putTagcp(chunks, "data"); - putU32cp(chunks, dataBytes); - for (float f : samples) putF32cp(chunks, f); - - std::vector wav; - putTagcp(wav, "RIFF"); - putU32cp(wav, static_cast(4 + chunks.size())); - putTagcp(wav, "WAVE"); - wav.insert(wav.end(), chunks.begin(), chunks.end()); - return wav; -} - -static void testHashWavContentIdenticalAudioSameHash() { - // Two WAVs with the same audio but different metadata body -> same hash. - // This is the core dedup regression: REAPER embeds a bext chunk with a - // render-time origination timestamp; without WAV-aware hashing, two renders - // of the same clip produce different file bytes -> no dedup collapse. - const std::vector audio = {0.1f, -0.2f, 0.3f, -0.4f}; - std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) - std::vector metaB(64, 0x00); - // Different origination timestamps: first 10 bytes of bext are ASCII date/time. - metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year - - auto wavA = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaA); - auto wavB = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaB); - - // Files must differ (the bext body is different) to prove the test is valid. - CHECK(wavA != wavB); - - // But their content hashes must be equal: same fmt+data, different metadata. - CHECK(hashWavContent(wavA) == hashWavContent(wavB)); -} - -static void testHashWavContentDifferentAudioDifferentHash() { - // Different PCM data -> different content hashes (no false dedup). - const std::vector audioA = {0.5f, 0.5f}; - const std::vector audioB = {0.5f, 0.6f}; // last sample differs - - auto wavA = buildTestWav(1, 44100, audioA); - auto wavB = buildTestWav(1, 44100, audioB); - - CHECK(hashWavContent(wavA) != hashWavContent(wavB)); -} - -static void testHashWavContentDifferentFmtDifferentHash() { - // Different fmt fields (sample rate) -> different content hashes. - const std::vector audio = {0.1f, 0.2f}; - auto wav44 = buildTestWav(1, 44100, audio); - auto wav48 = buildTestWav(1, 48000, audio); - CHECK(hashWavContent(wav44) != hashWavContent(wav48)); -} - -static void testHashWavContentNonWavFallsBackToWholeFile() { - // Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty - // and equals hashBytes of the same bytes directly. - std::vector notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02}; - const std::string h = hashWavContent(notWav); - CHECK(!h.empty()); - CHECK(h.size() == 16); - CHECK(h == hashBytes(notWav.data(), notWav.size())); -} - -static void testHashWavContentEmptyFallsBackToHashBytes() { - // Empty vector -> falls back to whole-file hashBytes (the FNV offset basis). - std::vector empty; - const std::string h = hashWavContent(empty); - CHECK(!h.empty()); - CHECK(h.size() == 16); - CHECK(h == hashBytes(nullptr, 0)); -} - -static void testHashWavContentListMetaSkipped() { - // A LIST/INFO chunk (another common metadata chunk) is likewise skipped. - const std::vector audio = {1.0f, -1.0f, 0.5f}; - std::vector listBody = {'I','N','F','O', 'x','x','x','x'}; - auto wavClean = buildTestWav(1, 48000, audio); - auto wavList = buildTestWav(1, 48000, audio, true, "LIST", listBody); - - // Content hashes must match: only the LIST chunk differs. - CHECK(hashWavContent(wavClean) == hashWavContent(wavList)); -} - -static void testHashWavContentDomainSeparationFromWholeFile() { - // The content hash ('W'-prefixed) must not accidentally equal the whole-file - // hash of the SAME bytes. This guards against the domain-separation prefix - // being dropped or zeroed out. - const std::vector audio = {0.0f}; - auto wav = buildTestWav(1, 44100, audio); - const std::string contentHash = hashWavContent(wav); - const std::string wholeHash = hashBytes(wav.data(), wav.size()); - CHECK(contentHash != wholeHash); -} +// NOTE (Q-W3, audit §4e): the hashBytes / hashWavContent tests moved to +// tests/test_wav_codec.cpp with the implementations — capture_paths is now path +// arithmetic only, with no content-hash / RIFF knowledge. // --- bankRelativeForName spelling consistency (Phase R, R2) ----------------- // @@ -597,18 +395,6 @@ int main() { testTransitionTwoUnsavedProjectsSwitchLoads(); testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps(); testTransitionInPlaceSaveIsNoOp(); - testHashBytesOutputFormat(); - testHashBytesDeterministic(); - testHashBytesDistinct(); - testHashBytesEmptyBufferIsNonEmpty(); - testHashBytesLargerBufferDiffersFromSmaller(); - testHashWavContentIdenticalAudioSameHash(); - testHashWavContentDifferentAudioDifferentHash(); - testHashWavContentDifferentFmtDifferentHash(); - testHashWavContentNonWavFallsBackToWholeFile(); - testHashWavContentEmptyFallsBackToHashBytes(); - testHashWavContentListMetaSkipped(); - testHashWavContentDomainSeparationFromWholeFile(); testBankRelativeForNameMatchesDerivePathSpelling(); testBankRelativeForNameConventionAndEdge(); diff --git a/tests/test_realtime_record.cpp b/tests/test_capture_realtime.cpp similarity index 97% rename from tests/test_realtime_record.cpp rename to tests/test_capture_realtime.cpp index 1da6e96..ca5b2f6 100644 --- a/tests/test_realtime_record.cpp +++ b/tests/test_capture_realtime.cpp @@ -1,9 +1,10 @@ -// Standalone tests for reasampler::realtime_record — no REAPER, no framework. +// Standalone tests for reasampler::capture_realtime (renamed from realtime_record +// in Q-W3 — the Q-9 naming rider) — no REAPER, no framework. // Covers the two pure pieces behind the realtime-record backend (M8): the // record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS) // and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping. -#include "../src/core/capture/realtime_record.h" +#include "../src/core/capture/capture_realtime.h" #include #include @@ -328,7 +329,7 @@ int main() { testStopRequestedClassification(); testIsTerminalPhaseClassification(); - if (g_fail == 0) std::printf("realtime_record: all tests passed\n"); - else std::printf("realtime_record: %d CHECK(s) FAILED\n", g_fail); + if (g_fail == 0) std::printf("capture_realtime: all tests passed\n"); + else std::printf("capture_realtime: %d CHECK(s) FAILED\n", g_fail); return g_fail == 0 ? 0 : 1; } diff --git a/tests/test_wav_trim.cpp b/tests/test_wav_codec.cpp similarity index 55% rename from tests/test_wav_trim.cpp rename to tests/test_wav_codec.cpp index 10b651d..f3038e6 100644 --- a/tests/test_wav_trim.cpp +++ b/tests/test_wav_codec.cpp @@ -1,13 +1,17 @@ -// Standalone tests for reasampler::wav_trim — no REAPER, no test framework. -// Builds synthetic 32-bit-float WAV byte buffers, asserts the parse geometry, the -// float extraction, and the truncate-plan arithmetic (the header size-field patch). +// Standalone tests for reasampler::wav_codec — no REAPER, no test framework. +// The ONE pure WAV/RIFF owner (Q-W3, audit §4e): builds synthetic 32-bit-float WAV +// byte buffers, asserts the parse geometry, the float extraction, the truncate-plan +// arithmetic + size-field patch, the float32 build round-trip, and the WAV-aware +// content hashes (moved here from capture_paths with the hash implementations). // // Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped; // format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame // extraction (whole / tail window / clamp / out-of-range); truncate plan (kept #include @@ -263,15 +267,10 @@ static void testTruncatePlanKeepFewer() { // Applying the plan yields a buffer that re-parses to exactly 4 frames. std::vector trimmed(wav.begin(), wav.begin() + p.newFileByteLength); - // Patch the two size fields (what the shell does before truncating on disk). - auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { - b[off + 0] = static_cast(v & 0xFF); - b[off + 1] = static_cast((v >> 8) & 0xFF); - b[off + 2] = static_cast((v >> 16) & 0xFF); - b[off + 3] = static_cast((v >> 24) & 0xFF); - }; - writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); - writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + // Patch the two size fields with the module's own patch primitive (what the + // shell does before truncating on disk). + patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize); + patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize); WavLayout L2 = parseWavLayout(trimmed); CHECK(L2.valid); @@ -325,14 +324,8 @@ static void testExtensibleFloatAccepted() { CHECK(p.valid); CHECK(p.newDataSize == 3 * 2 * 4u); std::vector trimmed(wav.begin(), wav.begin() + p.newFileByteLength); - auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { - b[off + 0] = static_cast(v & 0xFF); - b[off + 1] = static_cast((v >> 8) & 0xFF); - b[off + 2] = static_cast((v >> 16) & 0xFF); - b[off + 3] = static_cast((v >> 24) & 0xFF); - }; - writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); - writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize); + patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize); WavLayout L2 = parseWavLayout(trimmed); CHECK(L2.valid); CHECK(L2.frameCount() == 3); @@ -356,6 +349,233 @@ static void testTruncatePlanKeepZeroAndGrowRejected() { CHECK(!planWavTruncate(bad, 0).valid); } +// --- patchU32LE (the size-field patch primitive) ------------------------------ + +static void testPatchU32LEWritesLittleEndian() { + std::vector buf(8, 0xEE); + patchU32LE(buf, 2, 0x0A0B0C0Du); + CHECK(buf[0] == 0xEE && buf[1] == 0xEE); // bytes outside the field untouched + CHECK(buf[2] == 0x0D && buf[3] == 0x0C && buf[4] == 0x0B && buf[5] == 0x0A); + CHECK(buf[6] == 0xEE && buf[7] == 0xEE); +} + +// --- buildFloat32Wav (the one WAV writer, absorbed from ingest — T4-10) ------- + +static void testBuildFloat32WavGoldenHeaderAndRoundTrip() { + // 2 channels, 3 frames of known interleaved values. + const std::vector pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125}; + auto wav = buildFloat32Wav(2, 48000, 3, pcm); + + // Golden container shape: 44-byte header + 6 samples * 4 bytes. + CHECK(wav.size() == 44u + 6u * 4u); + CHECK(std::memcmp(wav.data(), "RIFF", 4) == 0); + CHECK(std::memcmp(wav.data() + 8, "WAVE", 4) == 0); + CHECK(std::memcmp(wav.data() + 12, "fmt ", 4) == 0); + CHECK(std::memcmp(wav.data() + 36, "data", 4) == 0); + CHECK(wav[20] == 0x03 && wav[21] == 0x00); // WAVE_FORMAT_IEEE_FLOAT + CHECK(wav[34] == 32 && wav[35] == 0); // bitsPerSample = 32 + + // The build round-trips through the module's own parse + extraction, with the + // documented double->float narrowing. + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + CHECK(L.channelCount == 2); + CHECK(L.sampleRate == 48000); + CHECK(L.frameCount() == 3); + auto back = extractFloatFrames(wav, L, 0, 3); + CHECK(back.size() == 6); + for (std::size_t i = 0; i < back.size(); ++i) + CHECK(back[i] == static_cast(pcm[i])); +} + +static void testBuildFloat32WavShortInputRejectedByParse() { + // Fewer interleaved samples than frameCount*nch declares: the data chunk still + // declares the full length; the missing tail simply is not written. The build + // caller (ingest) always passes a full buffer; this locks the clamp-no-OOB shape. + const std::vector pcm = {1.0}; // 1 sample for a 2-frame mono request + auto wav = buildFloat32Wav(1, 44100, 2, pcm); + // Declared data size covers 2 frames; actual bytes stop after 1 sample, so the + // declared length overruns the buffer -> parse rejects (the honest verdict for + // a short-fed build; ingest never produces this). + CHECK(wav.size() == 44u + 4u); + CHECK(!parseWavLayout(wav).valid); +} + +// --- hashBytes (FNV-1a content hash — moved with the impl from capture_paths) -- + +static void testHashBytesOutputFormat() { + // Output is always 16 lowercase hex characters. + const std::uint8_t bytes[] = {0x01, 0x02, 0x03}; + const std::string h = hashBytes(bytes, 3); + CHECK(h.size() == 16); + for (char c : h) { + CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); + } +} + +static void testHashBytesDeterministicAndDistinct() { + // Same input always produces the same output; different inputs differ (no + // accidental dedup of distinct files), including a one-bit flip and a + // trailing-zero-byte length change. + const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01}; + CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5)); + + std::uint8_t a[] = {0x00, 0x00}; + std::uint8_t b[] = {0x00, 0x01}; + CHECK(hashBytes(a, 2) != hashBytes(b, 2)); + + const std::uint8_t short_buf[] = {0xAB, 0xCD}; + const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00}; + CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3)); +} + +static void testHashBytesEmptyBufferIsNonEmpty() { + // An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty + // sentinel) — capturing the contract that even empty inputs yield a 16-char hash. + const std::string h = hashBytes(nullptr, 0); + CHECK(h.size() == 16); +} + +// --- hashWavContent (WAV-aware dedup hash) ----------------------------------- +// +// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata +// chunks like bext/LIST), falls back gracefully for non-WAV input, and that +// different audio data yields different hashes. + +// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk +// inserted between "WAVE" and the fmt chunk. This is the shape REAPER produces: a +// `bext` or `LIST` chunk before fmt with a render-time timestamp in the body. +static std::vector buildMetaWav( + std::uint16_t channels, std::uint32_t sampleRate, + const std::vector& samples, + bool insertMeta = false, + const char* metaTag = "bext", + const std::vector& metaBody = {}) { + + std::vector chunks; + + if (insertMeta && !metaBody.empty()) { + putTag(chunks, metaTag); + putU32(chunks, static_cast(metaBody.size())); + chunks.insert(chunks.end(), metaBody.begin(), metaBody.end()); + if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad + } + + // fmt chunk (16-byte body, IEEE-float tag 3). + const std::uint32_t dataBytes = + static_cast(samples.size() * 4u); + putTag(chunks, "fmt "); + putU32(chunks, 16); + putU16(chunks, 3); // IEEE float + putU16(chunks, channels); + putU32(chunks, sampleRate); + putU32(chunks, sampleRate * channels * 4u); // byteRate + putU16(chunks, static_cast(channels * 4)); // blockAlign + putU16(chunks, 32); // bitsPerSample + + // data chunk. + putTag(chunks, "data"); + putU32(chunks, dataBytes); + for (float f : samples) putFloat(chunks, f); + + std::vector wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(4 + chunks.size())); + putTag(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +static void testHashWavContentIdenticalAudioSameHash() { + // Two WAVs with the same audio but different metadata body -> same hash. + // This is the core dedup regression: REAPER embeds a bext chunk with a + // render-time origination timestamp; without WAV-aware hashing, two renders + // of the same clip produce different file bytes -> no dedup collapse. + const std::vector audio = {0.1f, -0.2f, 0.3f, -0.4f}; + std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) + std::vector metaB(64, 0x00); + // Different origination timestamps: first 10 bytes of bext are ASCII date/time. + metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year + + auto wavA = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaA); + auto wavB = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaB); + + // Files must differ (the bext body is different) to prove the test is valid. + CHECK(wavA != wavB); + + // But their content hashes must be equal: same fmt+data, different metadata. + CHECK(hashWavContent(wavA) == hashWavContent(wavB)); +} + +static void testHashWavContentDifferentAudioDifferentHash() { + // Different PCM data -> different content hashes (no false dedup). + const std::vector audioA = {0.5f, 0.5f}; + const std::vector audioB = {0.5f, 0.6f}; // last sample differs + + auto wavA = buildMetaWav(1, 44100, audioA); + auto wavB = buildMetaWav(1, 44100, audioB); + + CHECK(hashWavContent(wavA) != hashWavContent(wavB)); +} + +static void testHashWavContentDifferentFmtDifferentHash() { + // Different fmt fields (sample rate) -> different content hashes. + const std::vector audio = {0.1f, 0.2f}; + auto wav44 = buildMetaWav(1, 44100, audio); + auto wav48 = buildMetaWav(1, 48000, audio); + CHECK(hashWavContent(wav44) != hashWavContent(wav48)); +} + +static void testHashWavContentNonWavFallsBackToWholeFile() { + // Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty + // and equals hashBytes of the same bytes directly. Empty input likewise. + std::vector notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02}; + const std::string h = hashWavContent(notWav); + CHECK(!h.empty()); + CHECK(h.size() == 16); + CHECK(h == hashBytes(notWav.data(), notWav.size())); + + std::vector empty; + const std::string he = hashWavContent(empty); + CHECK(he.size() == 16); + CHECK(he == hashBytes(nullptr, 0)); +} + +static void testHashWavContentListMetaSkipped() { + // A LIST/INFO chunk (another common metadata chunk) is likewise skipped. + const std::vector audio = {1.0f, -1.0f, 0.5f}; + std::vector listBody = {'I','N','F','O', 'x','x','x','x'}; + auto wavClean = buildMetaWav(1, 48000, audio); + auto wavList = buildMetaWav(1, 48000, audio, true, "LIST", listBody); + + // Content hashes must match: only the LIST chunk differs. + CHECK(hashWavContent(wavClean) == hashWavContent(wavList)); +} + +static void testHashWavContentDomainSeparationFromWholeFile() { + // The content hash ('W'-prefixed) must not accidentally equal the whole-file + // hash of the SAME bytes. This guards against the domain-separation prefix + // being dropped or zeroed out. + const std::vector audio = {0.0f}; + auto wav = buildMetaWav(1, 44100, audio); + const std::string contentHash = hashWavContent(wav); + const std::string wholeHash = hashBytes(wav.data(), wav.size()); + CHECK(contentHash != wholeHash); +} + +static void testHashMatchesBuildOutput() { + // The consolidation guarantee end-to-end: a WAV produced by the module's own + // builder hashes as WAV content (not the whole-file fallback), so an imported + // conversion and a captured render of identical audio can dedup-collapse. + const std::vector pcm = {0.25, -0.25}; + auto wav = buildFloat32Wav(1, 44100, 2, pcm); + CHECK(hashWavContent(wav) != hashBytes(wav.data(), wav.size())); // chunk-aware path taken + // And a metadata-bearing copy of the same audio content hashes identically. + auto withMeta = buildMetaWav(1, 44100, {0.25f, -0.25f}, true, "bext", + std::vector(16, 0x7A)); + CHECK(hashWavContent(wav) == hashWavContent(withMeta)); +} + int main() { testParseCanonicalStereo(); testParseMonoAndLeadingChunk(); @@ -367,7 +587,21 @@ int main() { testTruncatePlanKeepZeroAndGrowRejected(); testExtensiblePcmIntegerRejected(); testExtensibleFloatAccepted(); + testPatchU32LEWritesLittleEndian(); + testBuildFloat32WavGoldenHeaderAndRoundTrip(); + testBuildFloat32WavShortInputRejectedByParse(); + testHashBytesOutputFormat(); + testHashBytesDeterministicAndDistinct(); + testHashBytesEmptyBufferIsNonEmpty(); + testHashWavContentIdenticalAudioSameHash(); + testHashWavContentDifferentAudioDifferentHash(); + testHashWavContentDifferentFmtDifferentHash(); + testHashWavContentNonWavFallsBackToWholeFile(); + testHashWavContentListMetaSkipped(); + testHashWavContentDomainSeparationFromWholeFile(); + testHashMatchesBuildOutput(); - if (g_fail == 0) std::printf("All tests passed.\n"); + if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); + else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail); return g_fail ? 1 : 0; } From 8bc5aa1257a6492c7c3076af792328fe45e1d7e8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:28:55 -0400 Subject: [PATCH 30/40] Q-W3 review follow-ups: golden hash literal test, CLAUDE.md wav_codec bullet, dead RecordedCapture field comment, makeUniqueTag residual note --- CLAUDE.md | 2 +- src/core/capture/capture_realtime.h | 10 ++++++++++ src/shell/capture/capture.cpp | 6 ++++++ tests/test_wav_codec.cpp | 20 +++++++++++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b8312f..4db0389 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, index-only move/copy/remove of a sample between banks, and JSON round-trip. - `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files. - `app_version` — REAPER-free version/channel identity: CMake-sourced semver constant, ext-state stamp value, and the full set of channel-derived identity accessors. All channel strings derive from one `REASAMPLER_CHANNEL_IS_BETA` bit; no scattered `#ifdef`s in the shells. -- `wav_trim` — 32-bit-float WAV parse + header-aware truncate plan for the realtime tail's PCM decay-scan trim. +- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner. (`wav_trim` is now a transitional forwarding alias onto `wav_codec`, kept only so the Q-W2v TUs it feeds compile untouched; retire it once that wave lands.) - `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** - `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them. - `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button. diff --git a/src/core/capture/capture_realtime.h b/src/core/capture/capture_realtime.h index b1da9c9..d497e8b 100644 --- a/src/core/capture/capture_realtime.h +++ b/src/core/capture/capture_realtime.h @@ -114,6 +114,16 @@ struct RecordedCapture { std::vector trackGuids; int channelCount = 0; + + // TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer + // populates these five fields before calling sampleFromRecordedCapture — the + // finalize path (capture_realtime_finalize.cpp) leaves them at their defaults + // and instead calls the shared stampCaptureSample(result.sample, ...) right + // after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/ + // captureTimeSigDenom/createdTimestamp directly, overwriting whatever + // sampleFromRecordedCapture set from these. Kept (not deleted) because the pure + // unit tests still construct/assert them directly; removing the fields is a + // struct-shape decision out of scope here. int sampleRate = 0; // 0 when the project rate was unknown (as offline) double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) // Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime). diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 377f74a..b68fd8a 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -239,6 +239,12 @@ std::string makeUniqueTag(const std::string& prefix) { // session distinct regardless of timing. NOTE: the tag varies the file NAME, // not the audio bytes — bit-identical-repeat is about identical *content* for // identical requests; two deliberate captures naturally live in two files. + // RESIDUAL (Q-W3 review follow-up): the counter is per-process, starting over + // at 0 on every REAPER launch/extension reload, so two separate REAPER + // instances (or a reload mid-session) can still mint the same timestamp+counter + // pair in the same wall-clock second — a same-second cross-process collision + // remains theoretically possible. Scoped to per-session deliberately: this fix + // targets the reachable-in-practice single-process batch-capture case above. static std::atomic counter{0}; const std::time_t now = std::time(nullptr); return prefix + std::to_string(static_cast(now)) + "-" + diff --git a/tests/test_wav_codec.cpp b/tests/test_wav_codec.cpp index f3038e6..b768e71 100644 --- a/tests/test_wav_codec.cpp +++ b/tests/test_wav_codec.cpp @@ -9,7 +9,9 @@ // extraction (whole / tail window / clamp / out-of-range); truncate plan (kept pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125}; + auto wav = buildFloat32Wav(2, 48000, 3, pcm); + CHECK(hashWavContent(wav) == "7ccf298c166a670a"); + CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44"); +} + int main() { testParseCanonicalStereo(); testParseMonoAndLeadingChunk(); @@ -600,6 +617,7 @@ int main() { testHashWavContentListMetaSkipped(); testHashWavContentDomainSeparationFromWholeFile(); testHashMatchesBuildOutput(); + testGoldenHashLiterals(); if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail); From 6108673c845faafeb4eba32b9e6f00112dd05c9d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:36:32 -0400 Subject: [PATCH 31/40] Q-W3 rebase fix: hoisted capture TUs include the Q-W2 panel seam headers (panel_bank_ops/panel_input) instead of the deleted bank_panel.h --- src/shell/capture/capture_batch.cpp | 3 ++- src/shell/capture/capture_orchestrator.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index 9893817..268a20a 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -14,7 +14,8 @@ #include #include -#include "bank_panel.h" // bankPanelSelectedSampleIds / Refresh +#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId +#include "shell/panel/panel_input.h" // bankPanelRefresh #include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome #include "core/model/bank_book.h" // BankBook / Bank #include "core/model/provenance.h" // recipe parse/build, fingerprint diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index c735bab..e106857 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -13,7 +13,7 @@ #include #include -#include "bank_panel.h" // bankPanelTailSetting / bankPanelRefresh +#include "shell/panel/panel_input.h" // bankPanelTailSetting / bankPanelRefresh #include "core/capture/tail_control.h" // TailSetting #include "core/model/provenance.h" // model::Provenance #include "ingest.h" // ingestAssignActiveInstance From 5232227323a461259a1e23f2f4ff1a2c33334934 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 11:43:22 -0400 Subject: [PATCH 32/40] =?UTF-8?q?docs(phase-q):=20record=20Q-W2=20+=20Q-W2?= =?UTF-8?q?v=20+=20Q-W3=20landings=20=E2=80=94=20points=20to=20COMPLETED.m?= =?UTF-8?q?d;=20ceiling=20overages=20recorded,=20in-DAW=20verification=20m?= =?UTF-8?q?arked=20pending,=20Q-W4=20dedupe-shape=20notes=20preserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMPLETED.md | 173 +++++++++++++++++++++++++++++++++++++++++++++++ PLAN.md | 185 +++++++++++++++++---------------------------------- 2 files changed, 234 insertions(+), 124 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index 613de11..6ca7464 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -2955,3 +2955,176 @@ unmoved. No REAPER type crosses into any `core/` file. identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this residual before assuming the split is finished. + +--- + +## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w2-panel`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Split the largest extension god-module (8+ responsibilities) along the audit's named +seams — eight TUs (Q-5 SETTLED with the T4-01 reshape, Daniel 2026-07-28): `panel_render` / +`panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / `panel_window` + +`panel_layout` (toolbar/footer/menu rects + row/cluster builders + region geometry glue) + +`panel_drag` (the card-drag/hover state machine — it already has a pure mirror, `card_drag`) — +without the two new seams, `panel_render` (~700) and `panel_input` (~800) would ship over the +~600 ceiling on day one. Split the fat `bank_panel.h` alongside (Interface Segregation). +Preserve the audition hot path as a direct call-through, never virtual. `panel_bank_ops` becomes +the single home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md +§Phase Q (bank_panel split seams; hot-path audition guardrail). See +`docs/product/code-organization.md` §2.1, §5. +**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/` and every +TU lands under the ~600-line ceiling (the Q-5 acceptance bar); the panel draws, thumbnails, +auditions, handles input, does bank ops, and manages its window exactly as before (no behavior +change — verify in DAW that the panel is visually and interactively unchanged). Audition/preview +call path stays a direct call-through (no virtual dispatch, no added header→TU indirection on +the preview path). The ~20-function public API is now segmented across the split headers. + +- [x] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache → + `panel_thumbnails`; toolbar/footer/menu rect + row/cluster builders + region geometry → + `panel_layout` (new seam, T4-01). +- [x] Split the audio audition/preview engine → `panel_audition` — direct call-through, not + virtual; preview idle path unchanged. +- [x] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window + lifecycle + OS drag-out/drop-target → `panel_window`; the card-drag/hover state machine → + `panel_drag` (new seam, T4-01). Per-mouse-move work stays plain free-function calls (T4-28). +- [x] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes + `actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I). +- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added; all eight TUs + under the ~600 ceiling. — **PENDING**: in-DAW panel-parity verification not yet performed on + `phase-q` (deferred by design). + +**Notes/decisions:** +- **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's + "silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613, + `panel_state.h` 608 — overage is comment volume; non-comment lines are ~322–369 per file; no + honest seam remains; bisection was rejected. +- **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed + prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)` + etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with + `actions.cpp` and are the cheapest first dedupe. +- ~50 TU-private helpers wrapped in anonymous namespaces (review follow-up landed in the same + merge). + +--- + +## Q-W2v — split the VST god-modules (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w2v-vst`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Close the audit's structural scope gap: the VST artifact's god-modules had no owning +wave, and `reasampler_editor.cpp` (3065 LOC) is the largest file in the repo. Split the editor +into eight TUs along the Sample/Browse/Zone face axis (T4-11): `editor_session` / +`editor_controls` / `editor_layout` (pure-candidate hoist into the existing pure homes — +`editor_geometry` is the named owner; this discharges T2-06's stranded-layout-math finding) / +`editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` / +`editor_input_browse_zone` / `editor_platform`. Split `reasampler_processor.cpp` (1164 LOC) into +three TUs (T4-12): `processor_state` / `processor_reload` / lifecycle+`process()` kept whole. +Split `sample_map` into resolution core vs the `component_state_io` binary codec + matching +header split (T4-13 ≡ T2-07 — the codec grows every envelope bump; the extension stops linking +the whole voice engine to serialize one preset blob). `sampler_core.cpp` stays whole (968 LOC) — +a DOCUMENTED hot-path exception to the ~600 ceiling (T4-14/T4-27: envelope `tick()`s run +per-voice-per-sample; same-TU definition is what lets the compiler inline the stack, no LTO in +the build; a by-class split is the exact heuristic-(3) dispatch blowout); its header splits into +`zone_params.h` + `sampler_core.h`. The `core/wire` LE byte-codec template (`putLE`/`readLE`, +T4-20) lands here with its biggest consumer. CONTEXT.md §Phase Q (VST split seams; `sampler_core` +exception). +**Verify:** CTest green at every commit. Editor and processor behave identically in DAW (visual ++ interactive parity across all three faces; `process()` audio unchanged). Every new TU lands +under the ~600-line ceiling except the one documented `sampler_core.cpp` exception. `process()` ++ its per-block helpers stay one TU; the atomic-pointer-swap reload pattern gains no virtual seam +(T4-29); no dispatch-stack blowout anywhere (heuristic 3). + +- [x] Split `reasampler_editor.cpp` → the eight face-axis TUs; hoist `editor_layout`'s pure + geometry into the existing pure homes (`editor_geometry` — discharges T2-06). +- [x] Split `reasampler_processor.cpp` → `processor_state` / `processor_reload` / + lifecycle+`process()` whole; no virtual seam on the atomic-swap pattern (T4-29). +- [x] Split `sample_map` → resolution core + `component_state_io` codec (+ header split); the + extension's preset-blob path stops linking the voice engine (T4-13 ≡ T2-07). +- [x] `sampler_core`: split `zone_params.h` out of the header; TU stays whole — documented + exception (T4-14/T4-27), recorded in the wave brief so nobody "fixes" it later. +- [x] Land the `core/wire` LE byte-codec template (`putLE`/`readLE`, T4-20) with + `component_state_io`; other consumers rewire opportunistically. +- [x] Rider: adopt the pure `ThumbnailKey` on the VST editor side (T2-10). +- [ ] Verify in DAW: editor + processor unchanged; CTest green; ceiling met (one documented + exception); no added dispatch. — **PENDING**: in-DAW editor/processor-parity verification not + yet performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- Golden full-blob v11 fixture pins the `component_state_io` codec bytes. +- The `src/vst/` directory is gone — all VST sources now live under the Q-W1 `core/instrument/` + and new `shell/instrument/` layout. +- **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h` + transitively (T2-07's header half — future work); the `engine` namespace is deferred + (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture + family. + +--- + +## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch) (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w3-main`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Reduce `main.cpp` (1897 LOC at the Q-W0 census) to its actual job — API pointers + +`ReaperPluginEntry` + dispatch — by hoisting four TUs (T4-02 reshape, SETTLED with Q-5, Daniel +2026-07-28 — the planned three left `capture_orchestrator` at ~885, over the ceiling): +`capture_orchestrator` (`RunCapture` / `captureAndIndexOne` / `renderOffline` / single-capture + +realtime/insert action bodies — lands ~450), `capture_batch` (the batch family + +`RunRecaptureFromSource` + the two RAII selection guards — recapture is planner-driven like batch +and shares the guard machinery), `scope_resolve` (`resolveRange`/`resolveRazorRange`/ +`collectSelectedTracks` + provenance assembly inputs), and `realtime_lifecycle` (the +realtime-capture state machine + globals). `FxBypassGuard` moves out but stays a stack RAII +object (precision-critical); the realtime idle tick stays a single pointer test. Q-W0 riders +owned by this wave (all SETTLED 2026-07-28): delete `ICaptureBackend` (T4-26 — one deriver, zero +polymorphic call sites; `OfflineRenderBackend` becomes concrete; the CLAUDE.md/CONTEXT "two +backends behind one interface" correction rides this wave's own commit, not earlier); the shared +`stampCaptureSample` capture-epilogue dedupe (T2-09); the `capture_realtime_finalize` split +riding the Q-9 rename (T4-08); the `makeUniqueTag` per-session monotonic-counter fix (T1-11); and +the WAV/RIFF consolidation (audit §4e) — one pure `wav_codec` owner (walker + layout + build + +patch), absorbing `ingest.cpp`'s pure WAV build helpers (T2-08 / T4-23 / T4-10). CONTEXT.md +§Phase Q (main split seams; FxBypassGuard + realtime-tick guardrails). See +`docs/product/code-organization.md` §2.1, §3. +**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves +identically in DAW; the null test still nulls, bit-identical repeats still match (the precision +invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*` +path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test. +`main.cpp` is now pointers + entry + dispatch only. The four hoisted TUs + `wav_codec` land under +the ~600 ceiling; the WAV/RIFF layout has one pure owner (the dedup-by-hash and null-test +invariants now rest on one implementation); `ICaptureBackend` is gone with no behavior change and +the CLAUDE.md/CONTEXT description is corrected in the same commit. + +- [x] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep + `FxBypassGuard` a stack RAII object as it moves (precision-invariant-critical). +- [x] Hoist the batch family + `RunRecaptureFromSource` + the two RAII selection guards → + `capture_batch` (fourth hoist, T4-02) so `capture_orchestrator` lands ~450. +- [x] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`. +- [x] Hoist the realtime-capture lifecycle state machine + globals → `realtime_lifecycle`; idle + tick stays a single pointer test. +- [x] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`. +- [x] Naming rider (Q-9 — SETTLED, Daniel 2026-07-28: yes): align the `capture_realtime` (shell) + / `realtime_record` (pure) word-order inversion to the house shell↔core convention — the pure + module takes the stem `capture_realtime`, the shell takes the suffix (`drag_out`↔`drag_out_win` + is the model). Split `capture_realtime_finalize` (async lifecycle vs file-side finalize) in the + same surgery (T4-08). No rename on a file this wave isn't already touching (Q-7). +- [x] Delete `ICaptureBackend` (T4-26): `OfflineRenderBackend` becomes concrete; correct the + CLAUDE.md/CONTEXT "two backends behind one interface" description in the same commit. +- [x] Dedupe the capture-stamp epilogue → shared `stampCaptureSample` (T2-09 — the divergent bits + stay in the realtime caller); fix `makeUniqueTag` with a per-session monotonic counter, both + call sites (T1-11 — same-second batch captures currently collide silently). +- [x] WAV/RIFF consolidation rider (audit §4e — SETTLED, Daniel 2026-07-28): one pure `wav_codec` + owner (chunk walker + layout + build + patch), absorbing `ingest.cpp`'s pure WAV/PCM build + (T4-10 — the ingest shell drops to ~500 and the WAV build gains a test target). +- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds; CTest + green; no realtime-tick branch-shape change. — **PENDING**: in-DAW null-test / + bit-identical-repeats verification not yet performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- `wav_codec_tests` replaces `wav_trim_tests`; `capture_realtime_tests` replaces + `realtime_record_tests`. +- **Known open:** `wav_trim.h`'s transitional forwarding shim still has three live includers + (`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named + follow-up; `ingest.cpp` trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` + `view` + remain the shim's unowned consumers). diff --git a/PLAN.md b/PLAN.md index 5624d3c..703d1a8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -405,137 +405,74 @@ before/after listening or null check. **The gate to Q-W1 is: triage complete + D > split wave (Q-W2 onward) retires its own includes of it as that module splits. ## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) -**Goal:** Split the largest extension god-module (8+ responsibilities) along the audit's named -seams — **eight TUs (Q-5 SETTLED with the T4-01 reshape, Daniel 2026-07-28)**: -`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / -`panel_window` **+ `panel_layout` (toolbar/footer/menu rects + row/cluster builders + region -geometry glue) + `panel_drag` (the card-drag/hover state machine — it already has a pure mirror, -`card_drag`)** — without the two new seams, `panel_render` (~700) and `panel_input` (~800) would -ship over the ~600 ceiling on day one. Split the fat `bank_panel.h` alongside (Interface -Segregation). **Preserve the -audition hot path as a direct call-through, never virtual.** `panel_bank_ops` becomes the single -home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md §Phase Q -(bank_panel split seams; hot-path audition guardrail). See `docs/product/code-organization.md` -§2.1, §5. -**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/` and -**every TU lands under the ~600-line ceiling** (the Q-5 acceptance bar); the panel -draws, thumbnails, auditions, handles input, does bank ops, and manages its window exactly as -before (no behavior change — verify in DAW that the panel is visually and interactively -unchanged). Audition/preview call path stays a **direct call-through** (no virtual dispatch, no -added header→TU indirection on the preview path). The ~20-function public API is now segmented -across the split headers. -**Depends on:** Q-W1 (directory/namespace layout established). Independently landable. -**Parallel-safe with Q-W2v** (different artifact, zero file overlap — §4f SETTLED). -- [ ] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache → - `panel_thumbnails`; toolbar/footer/menu rect + row/cluster builders + region geometry → - **`panel_layout`** (new seam, T4-01). -- [ ] Split the audio audition/preview engine → `panel_audition` — **direct call-through, not - virtual; preview idle path unchanged.** -- [ ] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window - lifecycle + OS drag-out/drop-target → `panel_window`; the card-drag/hover state machine → - **`panel_drag`** (new seam, T4-01). Per-mouse-move work stays plain free-function calls - (T4-28). -- [ ] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes - `actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I). -- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added; all eight TUs - under the ~600 ceiling. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w2-panel`). Integrated suite 61/61 green, +> reviewed-approved.** `bank_panel.cpp` (3459 LOC) split into eight TUs under `shell/panel/`: +> `panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / +> `panel_window` / `panel_layout` / `panel_drag`, plus per-seam public headers and internal +> `panel_state.h`; audition stays a direct call-through; the one-bank-op-one-undo invariant is +> preserved; ~50 TU-private helpers wrapped in anonymous namespaces (a review follow-up). See +> `COMPLETED.md` for the full narrative. +> +> **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's +> "silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613, +> `panel_state.h` 608 — the overage is comment volume; non-comment lines are ~322–369 per file; +> no honest seam remains; bisection was rejected. +> +> **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed +> prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)` +> etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with +> `actions.cpp` and are the cheapest first dedupe. +> +> **In-DAW verification (panel parity) is PENDING on `phase-q`** — deferred by design, not yet +> performed. ## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2) -**Goal:** Close the audit's structural scope gap: the VST artifact's god-modules had no owning -wave, and `reasampler_editor.cpp` (3065 LOC) is the largest file in the repo. Split the editor -into **eight TUs along the Sample/Browse/Zone face axis** (T4-11): `editor_session` / -`editor_controls` / `editor_layout` (**pure-candidate hoist** into the existing pure homes — -`editor_geometry` is the named owner; this discharges T2-06's stranded-layout-math finding) / -`editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` / -`editor_input_browse_zone` / `editor_platform`. Split `reasampler_processor.cpp` (1164 LOC) into -**three TUs** (T4-12): `processor_state` / `processor_reload` / lifecycle+`process()` kept -whole. Split `sample_map` into resolution core vs the **`component_state_io`** binary codec + -matching header split (T4-13 ≡ T2-07 — the codec grows every envelope bump; the extension stops -linking the whole voice engine to serialize one preset blob). **`sampler_core.cpp` stays whole -(968 LOC) — a DOCUMENTED hot-path exception to the ~600 ceiling** (T4-14/T4-27: envelope -`tick()`s run per-voice-per-sample; same-TU definition is what lets the compiler inline the -stack, no LTO in the build; a by-class split is the exact heuristic-(3) dispatch blowout); its -header splits into `zone_params.h` + `sampler_core.h`. The `core/wire` LE byte-codec template -(`putLE`/`readLE`, T4-20) lands here with its biggest consumer. CONTEXT.md §Phase Q (VST split -seams; `sampler_core` exception). -**Verify:** CTest green at every commit. Editor and processor behave identically in DAW (visual -+ interactive parity across all three faces; `process()` audio unchanged). Every new TU lands -under the ~600-line ceiling **except the one documented `sampler_core.cpp` exception**. -`process()` + its per-block helpers stay one TU; the atomic-pointer-swap reload pattern gains -**no virtual seam** (T4-29); no dispatch-stack blowout anywhere (heuristic 3). -**Depends on:** Q-W1 (layout + the T4-18 `instrument/` placement established). **Parallel-safe -with Q-W2** (different artifact, zero file overlap — §4f SETTLED, Daniel 2026-07-28; the serial -"Q-W7" alternative was set aside). -- [ ] Split `reasampler_editor.cpp` → the eight face-axis TUs; hoist `editor_layout`'s pure - geometry into the existing pure homes (`editor_geometry` — discharges T2-06). -- [ ] Split `reasampler_processor.cpp` → `processor_state` / `processor_reload` / - lifecycle+`process()` whole; **no virtual seam on the atomic-swap pattern** (T4-29). -- [ ] Split `sample_map` → resolution core + `component_state_io` codec (+ header split); the - extension's preset-blob path stops linking the voice engine (T4-13 ≡ T2-07). -- [ ] `sampler_core`: split `zone_params.h` out of the header; **TU stays whole — documented - exception** (T4-14/T4-27), recorded in the wave brief so nobody "fixes" it later. -- [ ] Land the `core/wire` LE byte-codec template (`putLE`/`readLE`, T4-20) with - `component_state_io`; other consumers rewire opportunistically. -- [ ] Rider: adopt the pure `ThumbnailKey` on the VST editor side (T2-10). -- [ ] Verify in DAW: editor + processor unchanged; CTest green; ceiling met (one documented - exception); no added dispatch. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w2v-vst`). Integrated suite 61/61 green, +> reviewed-approved.** `reasampler_editor.cpp` (3084 LOC, the largest file in the repo) split +> into eight face-axis TUs under `shell/instrument/`, with pure layout hoisted into +> `core/instrument/ui/editor_geometry` (discharges T2-06, newly tested); `reasampler_processor.cpp` +> split into `processor_state` / `processor_reload` / lifecycle+`process()` kept whole (no +> virtual seam, T4-29); `sample_map` split into a resolution core + `component_state_io` codec +> (the extension preset path no longer links the voice engine — link-proven; T4-13 ≡ T2-07); +> `sampler_core.cpp` stays whole with the documented hot-path exception comment (T4-14/T4-27); +> `zone_params.h` split out; `core/wire/bytes.h` (`putLE`/`ByteReader`) lands (T4-20); +> `ThumbnailKey` adopted (T2-10); a golden full-blob v11 fixture pins the codec bytes. The +> `src/vst/` directory is gone. See `COMPLETED.md` for the full narrative. +> +> **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h` +> transitively (T2-07's header half — future work); the `engine` namespace is deferred +> (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture +> family. +> +> **In-DAW verification (editor/processor parity) is PENDING on `phase-q`** — deferred by +> design, not yet performed. ## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch) -**Goal:** Reduce `main.cpp` (1897 LOC at the Q-W0 census) to its actual job — API pointers + -`ReaperPluginEntry` + dispatch — by hoisting **four** TUs (T4-02 reshape, SETTLED with Q-5, -Daniel 2026-07-28 — the planned three left `capture_orchestrator` at ~885, over the ceiling): -`capture_orchestrator` (`RunCapture` / `captureAndIndexOne` / `renderOffline` / single-capture + -realtime/insert action bodies — lands ~450), **`capture_batch`** (the batch family + -`RunRecaptureFromSource` + the two RAII selection guards — recapture is planner-driven like -batch and shares the guard machinery), `scope_resolve` -(`resolveRange`/`resolveRazorRange`/`collectSelectedTracks` + provenance assembly inputs), and -`realtime_lifecycle` (the realtime-capture state machine + globals). -**`FxBypassGuard` moves out but stays a stack RAII object (precision-critical); the realtime idle -tick stays a single pointer test.** **Q-W0 riders owned by this wave (all SETTLED 2026-07-28):** -delete `ICaptureBackend` (T4-26 — one deriver, zero polymorphic call sites; `OfflineRenderBackend` -becomes concrete; the CLAUDE.md/CONTEXT "two backends behind one interface" correction **rides -this wave's own commit**, not earlier); the shared `stampCaptureSample` capture-epilogue dedupe -(T2-09); the `capture_realtime_finalize` split riding the Q-9 rename (T4-08); the `makeUniqueTag` -per-session monotonic-counter fix (T1-11); and the **WAV/RIFF consolidation (audit §4e)** — one -pure **`wav_codec`** owner (walker + layout + build + patch), absorbing `ingest.cpp`'s pure WAV -build helpers (T2-08 / T4-23 / T4-10). CONTEXT.md §Phase Q (main split seams; FxBypassGuard + -realtime-tick guardrails). See `docs/product/code-organization.md` §2.1, §3. -**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves -identically in DAW; the null test still nulls, bit-identical repeats still match (the precision -invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*` -path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test. -`main.cpp` is now pointers + entry + dispatch only. The four hoisted TUs + `wav_codec` land -under the ~600 ceiling; the WAV/RIFF layout has **one** pure owner (the dedup-by-hash and -null-test invariants now rest on one implementation); `ICaptureBackend` is gone with no behavior -change and the CLAUDE.md/CONTEXT description is corrected in the same commit. -**Depends on:** Q-W1. Independent of Q-W2/Q-W2v. -- [ ] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep - `FxBypassGuard` a **stack RAII** object as it moves (precision-invariant-critical). -- [ ] Hoist the batch family + `RunRecaptureFromSource` + the two RAII selection guards → - **`capture_batch`** (fourth hoist, T4-02) so `capture_orchestrator` lands ~450. -- [ ] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`. -- [ ] Hoist the realtime-capture lifecycle state machine + globals → `realtime_lifecycle`; - **idle tick stays a single pointer test.** -- [ ] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`. -- [ ] **Naming rider (Q-9 — SETTLED, Daniel 2026-07-28: yes):** align the `capture_realtime` - (shell) / `realtime_record` (pure) word-order inversion to the house shell↔core convention — - the pure module takes the stem `capture_realtime`, the shell takes the suffix - (`drag_out`↔`drag_out_win` is the model). Split `capture_realtime_finalize` (async lifecycle - vs file-side finalize) in the same surgery (T4-08). No rename on a file this wave isn't - already touching (Q-7). -- [ ] Delete `ICaptureBackend` (T4-26): `OfflineRenderBackend` becomes concrete; correct the - CLAUDE.md/CONTEXT "two backends behind one interface" description **in the same commit**. -- [ ] Dedupe the capture-stamp epilogue → shared `stampCaptureSample` (T2-09 — the divergent - bits stay in the realtime caller); fix `makeUniqueTag` with a per-session monotonic counter, - both call sites (T1-11 — same-second batch captures currently collide silently). -- [ ] **WAV/RIFF consolidation rider (audit §4e — SETTLED, Daniel 2026-07-28):** one pure - `wav_codec` owner (chunk walker + layout + build + patch), absorbing `ingest.cpp`'s pure - WAV/PCM build (T4-10 — the ingest shell drops to ~500 and the WAV build gains a test target). -- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds; - CTest green; no realtime-tick branch-shape change. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w3-main`). Integrated suite 61/61 green, +> reviewed-approved.** `app/main.cpp` reduced 1897 → 653 LOC (pointers + entry + dispatch; the +> remaining bulk is the registration residue Q-W6 dissolves) via four hoists into +> `shell/capture/`: `capture_orchestrator`, `capture_batch`, `scope_resolve`, +> `realtime_lifecycle`; `FxBypassGuard` moved intact as a stack RAII object; the realtime idle +> tick stays a single pointer test; `ICaptureBackend` deleted (T4-26) with the +> CLAUDE.md/CONTEXT-ARCHIVE corrections landed in the same commit; the Q-9 rename done (pure +> `core/capture/capture_realtime`, shell `capture_realtime_shell` + `capture_realtime_finalize` +> split, T4-08); `stampCaptureSample` dedupe (T2-09, divergent time-sig behavior preserved via +> caller arg); `makeUniqueTag` gains a per-session monotonic counter (T1-11 behavior fix — stems +> now `-` / `rt--`; the per-process residual is documented in-code); one pure +> `wav_codec` RIFF owner absorbs `wav_trim` + `ingest`'s WAV build + content hashes, with golden +> hash literals pinned (`wav_codec_tests` replaces `wav_trim_tests`; `capture_realtime_tests` +> replaces `realtime_record_tests`). See `COMPLETED.md` for the full narrative. +> +> **Known open:** `wav_trim.h`'s transitional forwarding shim still has three live includers +> (`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named +> follow-up; `ingest.cpp` is trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` + +> `view` remain the shim's unowned consumers). +> +> **In-DAW verification (null test, bit-identical repeats) is PENDING on `phase-q`** — deferred +> by design, not yet performed. ## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` **Goal:** Split the two unrelated command-id families in one TU (1016 LOC at the Q-W0 census — From 430e11762003345369e532494299530648e82320 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 12:56:02 -0400 Subject: [PATCH 33/40] Q-W4: split actions.cpp into design_view_actions/bank_actions/prune_action + shared action_registry; bank verbs deduped into promptless bankOp* inner verbs in panel_bank_ops (one mutation home, two UX skins); command-id strings byte-identical; actions.h shim carrier retired --- CMakeLists.txt | 7 +- src/actions.cpp | 1019 -------------------- src/actions.h | 93 -- src/app/main.cpp | 3 +- src/core/model/provenance.h | 2 +- src/core/version/app_version.h | 2 +- src/ingest.cpp | 5 +- src/shell/actions/action_registry.cpp | 49 + src/shell/actions/action_registry.h | 29 + src/shell/actions/bank_actions.cpp | 366 +++++++ src/shell/actions/bank_actions.h | 45 + src/shell/actions/design_view_actions.cpp | 383 ++++++++ src/shell/actions/design_view_actions.h | 38 + src/shell/actions/prune_action.cpp | 102 ++ src/shell/actions/prune_action.h | 22 + src/shell/capture/capture_orchestrator.cpp | 2 +- src/shell/capture/track_guid.h | 2 +- src/shell/panel/panel_bank_ops.cpp | 322 +++++-- src/shell/panel/panel_bank_ops.h | 86 +- src/shell/panel/panel_drag.cpp | 2 +- src/shell/panel/panel_input.cpp | 2 +- src/shell/panel/panel_layout.cpp | 2 +- src/shell/panel/panel_layout.h | 2 +- src/shell/panel/panel_state.h | 9 +- 24 files changed, 1353 insertions(+), 1241 deletions(-) delete mode 100644 src/actions.cpp delete mode 100644 src/actions.h create mode 100644 src/shell/actions/action_registry.cpp create mode 100644 src/shell/actions/action_registry.h create mode 100644 src/shell/actions/bank_actions.cpp create mode 100644 src/shell/actions/bank_actions.h create mode 100644 src/shell/actions/design_view_actions.cpp create mode 100644 src/shell/actions/design_view_actions.h create mode 100644 src/shell/actions/prune_action.cpp create mode 100644 src/shell/actions/prune_action.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f643210..c491bb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -365,7 +365,7 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge # FX-chain identity fold, and the pure parent-detection decision (resample-from- # sample by resolved file path). Split out so the encoding + decision logic are # unit-tested outside the DAW; the FX-chain query, capture re-run, and action -# registration stay in the shell (main.cpp / actions.cpp). No dependency on +# registration stay in the shell (main.cpp / shell/actions/). No dependency on # bank_model — it takes plain strings/values at its boundary. # --------------------------------------------------------------------------- add_library(provenance STATIC src/core/model/provenance.cpp) @@ -1123,7 +1123,10 @@ add_library(reaper_reasampler MODULE src/core/view/guid_diff.cpp src/core/view/lane_keys.cpp src/shell/capture/item_read.cpp - src/actions.cpp + src/shell/actions/action_registry.cpp + src/shell/actions/design_view_actions.cpp + src/shell/actions/bank_actions.cpp + src/shell/actions/prune_action.cpp src/ingest.cpp src/core/model/bank_book.cpp src/core/model/owned_manifest.cpp diff --git a/src/actions.cpp b/src/actions.cpp deleted file mode 100644 index 488d6a0..0000000 --- a/src/actions.cpp +++ /dev/null @@ -1,1019 +0,0 @@ -#include "core/namespaces.h" -// actions.cpp — the Design View action family (Phase D4). See actions.h. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers -// (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the -// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off -// them, so they must never change after ship. -// -// Each action: -// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the -// active mode via toggle/activate) — the pure D1 state, -// 2. reapplies the active mode through the D2 view shell (applyMode) so the change -// takes visible effect immediately (tagging a track into Design while in Arrange -// parks it right away; a mode change re-partitions and re-parks in one step). -// -// Selection-driven mutations iterate the CURRENT REAPER track selection -// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct: -// the master is never tagged) and resolve each track to its canonical GUID key via -// the shared guidString helper, so the keys match exactly what the D2 shell / view -// tree key on (the cross-module key contract). - -#include "actions.h" - -#include -#include -#include - -#include "core/version/app_version.h" // channelCommandId / channelActionName — one channel-identity point - -#include "core/model/bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) -#include "shell/panel/panel_bank_ops.h" // selection seam (B3/B4) -#include "shell/panel/panel_layout.h" // full-height toggles (B3) -#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint -#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) -#include "core/view/lane_keys.h" // isOnManualLane — the single managed/manual predicate -#include "persist.h" // ReaSamplerSession (owns book() + view() model) -#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key -#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) -#include "core/view/view_mode_model.h" - -#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_CountSelectedTracks -#define REAPERAPI_WANT_GetSelectedTrack -#define REAPERAPI_WANT_CountSelectedMediaItems -#define REAPERAPI_WANT_GetSelectedMediaItem -#define REAPERAPI_WANT_GetMediaItemTrack -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_Main_SaveProject -#define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_GetUserInputs -#define REAPERAPI_WANT_ShowMessageBox -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended -// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped -// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family -// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a -// persistent command id user keybindings key off — NEVER change a shipped suffix after ship. -constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; -constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; -constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; -constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; -constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; -constexpr const char* kIdUntag = "VIEW_UNTAG"; -constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; -// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same -// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. -constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; -constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; -constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS"; - -// The live session the actions mutate. Set once by designViewRegisterActions and -// read by the hookcommand handler. Not owned here (main.cpp owns g_session). -ReaSamplerSession* g_session = nullptr; - -// Minted command ids (0 until registration succeeds). Compared in the handler. -int g_cmdToggleMode = 0; -int g_cmdActivateArrange = 0; -int g_cmdActivateDesign = 0; -int g_cmdTagDesign = 0; -int g_cmdTagArrange = 0; -int g_cmdUntag = 0; -int g_cmdShowBoth = 0; -int g_cmdMoveItemsDesign = 0; -int g_cmdMoveItemsArrange = 0; -int g_cmdUntagItems = 0; - -// gaccel storage must outlive registration — REAPER holds each pointer until we -// mirror-unregister it. One per action. -gaccel_register_t g_accelToggleMode{}; -gaccel_register_t g_accelActivateArrange{}; -gaccel_register_t g_accelActivateDesign{}; -gaccel_register_t g_accelTagDesign{}; -gaccel_register_t g_accelTagArrange{}; -gaccel_register_t g_accelUntag{}; -gaccel_register_t g_accelShowBoth{}; -gaccel_register_t g_accelMoveItemsDesign{}; -gaccel_register_t g_accelMoveItemsArrange{}; -gaccel_register_t g_accelUntagItems{}; - -// Durable store of composed, channel-qualified strings (ids + labels). A std::deque never -// invalidates references on push_back, so a c_str() handed to REAPER (a command_id at -// register, a gaccel desc for its lifetime) stays valid until process exit. Memoized by -// suffix so register and the mirror-unregister get the SAME id pointer for a given action. -std::deque g_strStore; - -// Returns the channel-qualified command id for `suffix`, interning it once. Called by BOTH -// registerAction and the unregister path, so a '-command_id' presents the identical string. -const char* channelIdFor(const char* suffix) { - const std::string composed = channelCommandId(suffix); - for (const std::string& s : g_strStore) - if (s == composed) return s.c_str(); - g_strStore.push_back(composed); - return g_strStore.back().c_str(); -} - -// Mints a command id from a channel-qualified SUFFIX and registers its gaccel (Actions-list -// entry with a channel-qualified label PHRASE). Returns the command id (0 on failure). Both -// the composed id and label are interned durably (g_strStore) — REAPER holds the desc -// pointer, and the id must survive to the mirror-unregister. The gaccel storage itself is -// caller-owned (the file-scope g_accel* above). -int registerAction(reaper_plugin_info_t* rec, const char* suffix, - gaccel_register_t& accel, const char* phrase) { - const char* id = channelIdFor(suffix); - const int cmd = rec->Register("command_id", (void*)id); - if (cmd) { - g_strStore.push_back(channelActionName(phrase)); - accel.accel.cmd = cmd; - accel.desc = g_strStore.back().c_str(); - rec->Register("gaccel", (void*)&accel); - } - return cmd; -} - -// Collects the canonical GUID keys of the current track selection. Empty if nothing -// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is -// exactly right — the master is never a tagged leaf. -std::vector selectedTrackGuids() { - std::vector guids; - const int n = CountSelectedTracks(nullptr); // nullptr = active project - guids.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaTrack* tr = GetSelectedTrack(nullptr, i); - if (!tr) continue; - std::string g = guidString(tr); - if (!g.empty()) guids.push_back(std::move(g)); - } - return guids; -} - -// Reapplies the model's CURRENT active mode to the active project so a membership -// mutation takes visible effect immediately (park/unpark/re-derive parents). Called -// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project. -void reapplyActiveMode() { - applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); -} - -// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to -// decide whether an item's lane name is meaningful for the manual-lane read. -constexpr int kFreeModeFixedLanes = 2; - -// Collects the current media-item selection as the pure decision's input: each selected -// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned). -// The manual-lane read follows the shared pure predicate exactly as the shell's readers -// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal -// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped. -// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). -std::vector selectedRetagItems() { - std::vector items; - const int n = CountSelectedMediaItems(nullptr); // nullptr = active project - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (!it) continue; - std::string g = itemGuid(it); - if (g.empty()) continue; - - MediaTrack* tr = GetMediaItemTrack(it); - const bool fixedLane = - tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; - // Only read the lane name on a fixed-lane track; the pure predicate handles the - // normal-track case (returns false) so we pass an empty name and skip the read. - const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; - items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); - } - return items; -} - -// Persists both the bank and the Design-View model to the active project's ext -// state. Called after every state-changing Design View action so the view model -// is not lost across save/close/reopen. Marking the project dirty is correct — -// a Design View mutation is a project-level change the user should be prompted -// to save. -// -// When the membership index is non-empty AND the project is unsaved, we prompt -// the user to Save-As before persisting — mirroring the flow capture uses. -// Gate: if membership is empty (no tracks tagged), skip the prompt entirely; -// saveToActiveProject will no-op for an unsaved project, which is correct. -// -// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and -// blocks until the user dismisses it. The blocking behaviour and dialog -// appearance can only be confirmed in a running REAPER (same caveat as capture). -void persistViewState() { - if (!g_session->view().membership().empty()) { - // At least one track is tagged — worth persisting. Check whether the - // project is saved and, if not, prompt Save-As so saveToActiveProject - // can write ext state. Mirrors capture's readRppPath idiom exactly. - ReaProject* proj = EnumProjects(-1, nullptr, 0); - if (proj) { - auto readRppPath = [&]() -> std::string { - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - return std::string(buf.data()); - }; - - if (readRppPath().empty()) { - // Project is unsaved — prompt Save-As. - Main_SaveProject(proj, true); - // Re-read: still empty means the user cancelled. - if (readRppPath().empty()) { - ShowConsoleMsg( - "ReaSampler: Design View state will not persist until " - "the project is saved.\n"); - // The in-session tag state is left as-is — the mode change - // already applied and remains valid for this session. - return; - } - } - } - } - g_session->saveToActiveProject(); -} - -// -- Action bodies --------------------------------------------------------- - -// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two -// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode -// itself sets the model's active mode, so we only compute the target and apply. -void doToggleMode() { - const std::string target = - nextModeId(g_session->view().modes(), g_session->view().activeModeId()); - if (target.empty()) return; // no modes to cycle to (degenerate) - applyMode(g_session->view(), target, nullptr); - persistViewState(); - bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately -} - -// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if -// the id is unregistered, so an absent mode fails safe. -void doActivateMode(const std::string& modeId) { - applyMode(g_session->view(), modeId, nullptr); - persistViewState(); - bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately -} - -// Tag the selection's leaves into `modeId`, then reapply so the change is immediate. -// tag() replaces any prior single-mode membership (a leaf lives in one mode; the -// cross-mode case is show-both), matching the D1 contract. -void doTag(const std::string& modeId) { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().tag(g, modeId); - reapplyActiveMode(); - persistViewState(); -} - -// Untag the selection entirely (return each to the Arrange default). This is the -// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the -// absence of a tag), so the two actions are the same act by definition. -void doUntag() { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().untag(g); - reapplyActiveMode(); - persistViewState(); -} - -// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each -// track and flip it independently (a mixed selection converges toward "all on" then -// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a -// multi-selection). show-both leaves are never parked (D1), so reapply reflects the -// change immediately. -void doShowBoth() { - MembershipIndex& m = g_session->view().membership(); - for (const std::string& g : selectedTrackGuids()) - m.setShowBoth(g, !m.isShowBoth(g)); - reapplyActiveMode(); - persistViewState(); -} - -// -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- -// -// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default), -// then re-drive the minting + apply path so each moved item lands on its target mode's -// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag -// decides which selected items to retag (manual-lane items are EXEMPT — never retagged, -// never re-laned), upholding the managed-lanes-only invariant even under this explicit -// user action. The whole structural act is wrapped in ONE Undo block with a descriptive -// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it). -// -// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which -// must NOT sit inside the Undo block, so we close the block first, then persist — the same -// separation the track actions rely on (they persist outside applyMode's own block). -void doMoveItems(const std::string& targetMode) { - const std::vector selected = selectedRetagItems(); - const std::vector ops = planItemRetag(selected, targetMode); - if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty - - MembershipIndex& membership = g_session->view().membership(); - - Undo_BeginBlock2(nullptr); - // Apply the pure decision's membership writes: tag into targetMode, or untag. - for (const ItemRetagOp& op : ops) { - if (op.untag) membership.untag(op.guid); - else membership.tag(op.guid, op.modeId); - } - // Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track - // whose items now span modes and assign each moved item to its mode's managed lane, - // then reassert the active mode's lane visibility. Manual lanes stay untouched - // (mintManagedLanes reports their items exempt and never mints over them). - mintManagedLanes(g_session->view(), nullptr); - reapplyActiveMode(); - - const std::string label = - targetMode.empty() - ? std::string("ReaSampler: untag selected items") - : std::string("ReaSampler: move selected items -> ") + targetMode; - Undo_EndBlock2(nullptr, label.c_str(), -1); - - persistViewState(); -} - -} // namespace - -void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { - g_session = session; - - // command_id -> gaccel for each. The single hookcommand that routes these lives - // in main.cpp (one hook per extension); designViewHandleCommand services them. - g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, - "toggle Design View mode"); - g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, - "activate mode Arrange"); - g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign, - "activate mode Design"); - g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign, - "tag selected tracks -> Design"); - g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange, - "tag selected tracks -> Arrange"); - g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag, - "untag selected tracks"); - g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, - "show both for selected tracks"); - - // Item-level mode moves (D2 W3-B): the item analog of the track tag family. - g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, - "move selected items -> Design"); - g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, - "move selected items -> Arrange"); - g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, - "untag selected items"); -} - -bool designViewHandleCommand(int command) { - if (command == 0 || !g_session) return false; - - if (command == g_cmdToggleMode) { doToggleMode(); return true; } - if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; } - if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; } - if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; } - // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag). - if (command == g_cmdTagArrange) { doUntag(); return true; } - if (command == g_cmdUntag) { doUntag(); return true; } - if (command == g_cmdShowBoth) { doShowBoth(); return true; } - - // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an - // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above. - if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; } - if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; } - if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; } - - return false; // not ours — caller's hookcommand keeps looking -} - -void designViewUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. - // gaccel first, then the command_id string (reverse of registration order — the item - // moves registered last, so they tear down first). - // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor - // returns the memoized pointer registered above), so the unregister matches exactly. - rec->Register("-gaccel", (void*)&g_accelUntagItems); - rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); - rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); - rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange)); - rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); - rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign)); - rec->Register("-gaccel", (void*)&g_accelShowBoth); - rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth)); - rec->Register("-gaccel", (void*)&g_accelUntag); - rec->Register("-command_id", (void*)channelIdFor(kIdUntag)); - rec->Register("-gaccel", (void*)&g_accelTagArrange); - rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange)); - rec->Register("-gaccel", (void*)&g_accelTagDesign); - rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign)); - rec->Register("-gaccel", (void*)&g_accelActivateDesign); - rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign)); - rec->Register("-gaccel", (void*)&g_accelActivateArrange); - rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange)); - rec->Register("-gaccel", (void*)&g_accelToggleMode); - rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode)); - - g_session = nullptr; -} - -// =========================================================================== -// Multi-bank action family (Phase B3) -// =========================================================================== -// -// Each action drives the B1 model on g_session->book() and persists via -// g_session->saveToActiveProject() so the change travels with the .rpp — exactly as -// the capture path persists a new Sample (main.cpp RunCapture). The book's rules -// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in bank_book; -// these handlers only call the model and react to the boolean / TransferResult. -// -// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index -// return a reference INTO the book's internal vector, which a create/delete can -// reallocate. No handler here caches a BankModel& (or a Bank*) across a structural -// mutation — each resolves ids to strings up front and re-resolves after any -// create/delete. Move/copy pass ids (not references) straight to moveSample/copySample. - -namespace { - -// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is -// prepended at register via channelCommandId (as with the Design View family above) — -// stable rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. -// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly -// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). -constexpr const char* kIdBankCreate = "BANK_CREATE"; -constexpr const char* kIdBankRename = "BANK_RENAME"; -constexpr const char* kIdBankDelete = "BANK_DELETE"; -constexpr const char* kIdBankEvacuate = "BANK_EVACUATE"; -constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT"; -constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL"; -constexpr const char* kIdBankMoveSel = "BANK_MOVE_SELECTED"; -constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; -constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; -constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; -constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; -// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so -// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion), -// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. -constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; - -int g_cmdBankCreate = 0; -int g_cmdBankRename = 0; -int g_cmdBankDelete = 0; -int g_cmdBankEvacuate = 0; -int g_cmdBankActivateNext = 0; -int g_cmdBankActivatePool = 0; -int g_cmdBankMoveSel = 0; -int g_cmdBankCopySel = 0; -int g_cmdBankRemoveSel = 0; -int g_cmdBankPoolFull = 0; -int g_cmdBankBanksFull = 0; -int g_cmdBankPruneFolder = 0; - -gaccel_register_t g_accelBankCreate{}; -gaccel_register_t g_accelBankRename{}; -gaccel_register_t g_accelBankDelete{}; -gaccel_register_t g_accelBankEvacuate{}; -gaccel_register_t g_accelBankActivateNext{}; -gaccel_register_t g_accelBankActivatePool{}; -gaccel_register_t g_accelBankMoveSel{}; -gaccel_register_t g_accelBankCopySel{}; -gaccel_register_t g_accelBankRemoveSel{}; -gaccel_register_t g_accelBankPoolFull{}; -gaccel_register_t g_accelBankBanksFull{}; -gaccel_register_t g_accelBankPruneFolder{}; - -// Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp -// RunCapture), NOT the Design-View path: a bank change is held in-session and written -// to the active project's ext state so it travels with the .rpp. Deliberately no -// Save-As prompt — saveToActiveProject no-ops on an unsaved project (the change stays -// valid for the session and persists on the user's next save), exactly as capture -// persists. This is an intentional divergence from persistViewState (above), which -// DOES prompt Save-As on an unsaved project; do not "align" the two — a bank mutation -// follows capture's quiet-persist idiom, a Design-View mutation follows the prompt idiom. -// Returns whether a persist actually happened (false on an unsaved/no-active project), -// so persistBankOp can skip its undo block when nothing was written. -bool persistBook() { return g_session->saveToActiveProject(); } - -// Persists a completed bank index verb (create/rename/reorder/delete/evacuate/ -// move/copy) as a SINGLE batched REAPER undo point (R-B) — one bank op = one Ctrl-Z. -// -// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project -// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures -// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents -// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). -// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family -// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them -// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs -// INSIDE the block so the post-mutation ext-state is the block's "after" image. -// -// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a -// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here, -// so no dangling/empty undo point is ever opened for a rejected op. -// -// Prompts the user for a single line of text via REAPER's stock input dialog. -// GetUserInputs(title, num_inputs=1, captions_csv, retvals_csv, sz) -> false on -// cancel (SDK ~3808). `initial` pre-fills the field. Returns false (leaving `out` -// untouched) on cancel or an empty entry. Self-contained bindable-action name entry; -// B4's panel affordances supersede this with in-panel editing. -// -// COMMA GUARD: GetUserInputs splits the returned values on a separator that defaults -// to ',', so a bank name containing a comma would be truncated at the comma. We -// override the return separator to \x1f (ASCII unit separator, un-typeable in the -// dialog) via the documented `separator=X` extra caption field (SDK ~3806), so any -// printable name — commas included — round-trips whole. The captions_csv itself stays -// comma-joined: the single field caption, then the `separator=` directive as a -// trailing pseudo-caption (the directive redefines only the RETURN separator). -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { - std::vector buf(512, '\0'); - // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. - std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); - const std::string captions = std::string(caption) + ",separator=\x1f"; - if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), static_cast(buf.size()))) - return false; // user cancelled - std::string s(buf.data()); - if (s.empty()) return false; // an empty name is not a valid bank name - out = std::move(s); - return true; -} - -// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model design: -// ids are caller-supplied and stable; the model stays pure and mints none). Distinct -// from a track GUID by origin only — both are canonical guidToString output. -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) - guidToString(&g, buf); - return std::string(buf); -} - -// Resolves a user-typed bank reference (a display name) to a bank id, scanning the -// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool. -// Returns "" when no bank carries that name. Kept in the action layer (not the model) -// — it is UI name-resolution, not a model rule. First-match is unambiguous BY -// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive), -// so at most one bank can carry a given name — no duplicate can shadow another here. -std::string bankIdByDisplayName(const std::string& name) { - for (const Bank& b : g_session->book().banks()) - if (b.displayName == name) return b.id; - return {}; -} - -// -- Action bodies --------------------------------------------------------- - -// Create a named bank: prompt for a display name, mint a stable GUID id, create it in -// the model, persist. The new bank is NOT auto-activated (create and activate are -// distinct acts — mirrors capture/placement separation). The model rejects a display -// name that duplicates an existing bank's (trimmed + case-insensitive, incl. "Pool"); -// the create then fails and the user is told the name is taken. -void doBankCreate() { - std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = mintBankId(); - if (!g_session->book().createBank(id, name)) { - ShowConsoleMsg( - ("ReaSampler: could not create bank \"" + name + - "\" (a bank with that name already exists).\n") - .c_str()); - return; - } - persistBankOp("ReaSampler: create bank"); -} - -// Rename a bank: prompt for which bank (by current display name) and the new name. -// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form -// self-contained; B4's panel renames in place on a tab. -void doBankRename() { - std::string which; - if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "", - which)) - return; - const std::string id = bankIdByDisplayName(which); - if (id.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); - return; - } - std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return; - if (!g_session->book().renameBank(id, newName)) { - // renameBank rejects the pool (un-renamable) or a name already used by another - // bank (unique display names, trimmed + case-insensitive). - ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " - "or another bank already uses that name).\n"); - return; - } - persistBankOp("ReaSampler: rename bank"); -} - -// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: -// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as -// the alternative before dropping them (a plain delete orphans those members' files -// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer -// panel confirm (naming evacuate inline, with a one-click evacuate) arrives in B4. -void doBankDelete() { - std::string which; - if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return; - const std::string id = bankIdByDisplayName(which); - if (id.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); - return; - } - // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here, - // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading - // "delete anyway?" prompt for an operation the model will refuse regardless. - if (id == kPoolBankId) { - ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n"); - return; - } - // Read member count BEFORE deleting (the Bank* is invalidated by deleteBank; we do - // not cache it — resolve size to an int up front). - const Bank* b = g_session->book().bank(id); - if (!b) return; // race-safe: id resolved above but re-check - const std::size_t members = b->index.size(); - if (members > 0) { - const std::string msg = - "\"" + which + "\" holds " + std::to_string(members) + - (members == 1 ? " sample" : " samples") + - ".\n\nDeleting drops them from every bank (their files are NOT deleted, " - "but no bank will reference them until prune).\n\nTo keep the samples, " - "cancel and Evacuate the bank to the pool first.\n\nDelete anyway?"; - const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4); - if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) - } - if (!g_session->book().deleteBank(id)) { - ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); - return; - } - // S9: bump only when the deleted bank held samples — dropping them changes what a live - // instance referencing one could play. Deleting an EMPTY bank is purely organizational. - persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); -} - -// Evacuate a named bank: move every member back to the pool (index-only, collapse by -// hash), leaving the bank empty. The pool is un-evacuable (the model rejects it). The -// intended "keep the samples" companion to delete. -void doBankEvacuate() { - std::string which; - if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", - which)) - return; - const std::string id = bankIdByDisplayName(which); - if (id.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); - return; - } - if (!g_session->book().evacuate(id)) { - ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " - "destination, not a source).\n"); - return; - } - // S9: evacuate moves members between banks (bank membership changes) -> bump. - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); -} - -// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), -// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the -// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and -// never touches the timeline. Persist so the active id travels with the .rpp. -void doBankActivateNext() { - std::vector ids; - ids.reserve(g_session->book().size()); - for (const Bank& b : g_session->book().banks()) ids.push_back(b.id); - const std::string target = nextBankId(ids, g_session->book().activeBankId()); - if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) - if (!g_session->book().setActiveBank(target)) return; - persistBankOp("ReaSampler: activate bank"); -} - -// Activate the pool directly (the common "back to the default target" jump). Bindable -// direct-by-id form; a general activate-bank-by-name/menu is a B4 affordance. -void doBankActivatePool() { - if (!g_session->book().setActiveBank(kPoolBankId)) return; - persistBankOp("ReaSampler: activate bank"); -} - -// Move or copy the panel's selected samples into a named destination bank (prompted -// by display name). The SOURCE is the bank the selection lives in — the focused -// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical -// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are -// index-only (files never relocate); move removes the source entry, copy retains it; -// both observe destination collapse-by-hash (bank_book). B4's "move to bank" menu -// drives moveSample/copySample directly with a menu-chosen destination — this bindable -// form is the same operation with a text-prompt destination. -void doBankTransferSelected(bool copy) { - const std::vector selected = bankPanelSelectedSampleIds(); - if (selected.empty()) { - ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to " - "move/copy.\n"); - return; - } - const char* verb = copy ? "copy" : "move"; - const std::string title = std::string("ReaSampler: ") + verb + " selected samples"; - std::string destName; - if (!promptText(title.c_str(), "Destination bank:", "", destName)) return; - const std::string destId = bankIdByDisplayName(destName); - if (destId.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); - return; - } - // Source = the bank the selection lives in (the focused region's displayed bank). - // Pass ids by value — no BankModel& is cached across the loop's mutations. - const std::string srcId = bankPanelSelectedSourceBankId(); - if (srcId == destId) { - ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); - return; - } - - // Tally per-sample transfer outcomes so the no-op guardrail below can decide whether - // the index actually mutated (R-B). The console summary m11 stripped is gone; the - // counts remain because the verb-aware undo guardrail is driven by them. - int ok = 0, collapsed = 0; - for (const std::string& sampleId : selected) { - const TransferResult r = - copy ? g_session->book().copySample(sampleId, srcId, destId) - : g_session->book().moveSample(sampleId, srcId, destId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - // RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are - // no-ops for the guardrail; nothing mutated for those ids. - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSameBank: break; - } - } - // No-op guardrail — VERB-AWARE (a collapse means different things per verb): - // * MOVE collapse: the source entry WAS removed (bank_book moveSample removes - // unconditionally before the dest add collapses on hash), so the index DID - // mutate — it counts toward opening an undo point. - // * COPY collapse: the source is left intact AND the dest already held the hash, - // so NOTHING changed — a true index no-op. It must NOT open an undo point. - // Hence: copy counts only real gains (ok); move counts gains OR collapses. - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (mutated) { - const std::string label = - std::string("ReaSampler: ") + verb + " sample(s)"; - // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an - // instance may reference) -> bump so assigned instances refresh hands-free. - persistBankOp(label.c_str(), /*bumpGeneration=*/true); - } -} - -// Remove the panel's selected samples from the SOURCE bank (the focused region's -// displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). Index-only -// and non-destructive to the file: a last-reference remove leaves the file on disk, -// orphaned until Phase R prune (remove NEVER deletes bytes — the manifest is untouched). -// -// SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks -// seam stays latent in the model; nothing here reaches for it. -// -// SILENT REMOVE: removes proceed without a confirm dialog. Recoverability is provided -// by the batched REAPER undo (R-B) — one Ctrl-Z restores the index entry. Files are -// never deleted by remove (orphaned-until-prune is unchanged). hashReferencedElsewhere -// is a tested model API retained for Phase R prune; it has no shell caller here. -void doBankRemoveSelected() { - const std::vector selected = bankPanelSelectedSampleIds(); - if (selected.empty()) { - ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n"); - return; - } - const std::string srcId = bankPanelSelectedSourceBankId(); - BankBook& book = g_session->book(); - if (book.bank(srcId) == nullptr) { - ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); - return; - } - - // Perform the removes (this-bank scope). Pass ids by value — no BankModel& is cached - // across the loop's mutations. Count real drops so the no-op guardrail can skip the - // undo point when nothing was removed (every id was already absent). - int removed = 0; - for (const std::string& sampleId : selected) { - if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - // RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail. - // (Unknown bank cannot occur — srcId was resolved to a live bank above.) - } - - // No-op guardrail (R-B): open an undo point only if the index actually mutated. - // S9: a remove drops a sample from a bank (an instance referencing it must refresh — it - // will resolve to silence, per the stale-id policy) -> bump. - if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); -} - -// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE -// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only), -// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest -// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then -// on explicit Yes delete EXACTLY that set (session->pruneReclaim, which recomputes the -// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback). -// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing. -// -// The full (untruncated) orphan set is captured here for the delete; the dry-run's -// truncated list is only the confirm's readout. No ext-state is written and no undo point -// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project -// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. -void doBankPruneFolder() { - const PruneReport report = g_session->pruneDryRun(); - - // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the - // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather - // than proceed with degraded protection. Distinct from "no orphans": the user must - // know the prune refused to run and why. - if (report.abortedUnreadableUsage) { - std::string msg = - "ReaSampler prune: ABORTED -- one or more instance usage records could not " - "be read or decoded. Nothing was deleted.\n" - "If the owning instance is still loaded it will republish its record on the " - "next poll tick, clearing the abort. If the instance no longer exists (the " - "key is an orphaned corrupt record), clear it manually via ReaScript:\n" - " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" - "Offending key(s):\n"; - for (const std::string& key : report.offendingUsageKeys) { - msg += " " + key + "\n"; - } - ShowConsoleMsg(msg.c_str()); - return; - } - - if (report.count == 0) { - ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n"); - return; - } - - // The EXACT set the delete will target — full, untruncated, so what the confirm - // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the - // confirm so the confirm and the delete reason about the same enumeration. - const std::vector orphanSet = g_session->pruneOrphanSet(); - - // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped - // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped. - std::string msg = - "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) + - " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n" - "These files are no longer referenced by any bank and were created by ReaSampler.\n" - "They will be moved to the Recycle Bin on Windows (recoverable), or deleted on " - "other platforms.\n\n"; - for (const std::string& rel : report.orphans) msg += " " + rel + "\n"; - if (report.truncated) { - msg += " ... (" + std::to_string(report.count - report.orphans.size()) + - " more not shown)\n"; - } - msg += "\nReclaim these files now?"; - - const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4); - if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544) - ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n"); - return; - } - - // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). - const PruneDeletionResult del = g_session->pruneReclaim(orphanSet); - - std::string done = "ReaSampler prune: reclaimed " + - std::to_string(del.reclaimedCount) + " file(s), " + - std::to_string(del.reclaimedBytes) + " bytes" + - (del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + "."; - if (del.skippedCount > 0) { - done += " " + std::to_string(del.skippedCount) + - " file(s) skipped (locked, or changed since the report)."; - } - done += "\n"; - ShowConsoleMsg(done.c_str()); -} - -} // namespace - -// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — -// one bank op = one Ctrl-Z. Declared in actions.h so bank_panel.cpp can call it -// without duplicating the undo logic. -// -// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project -// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures -// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents -// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). -// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family -// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them -// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs -// INSIDE the block so the post-mutation ext-state is the block's "after" image. -// -// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a -// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here, -// so no dangling/empty undo point is ever opened for a rejected op. -// -// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops -// (nothing is written to ext state). We must still CLOSE the block we opened, but with -// an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a -// no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model -// change stands and persists on the user's next save; it just earns no undo point until -// there is a project to persist into (undo of an unsaved bank op has nothing to roll -// back to anyway). The Begin/End must still be balanced, hence the close-either-way. -void persistBankOp(const char* label, bool bumpGeneration) { - Undo_BeginBlock2(nullptr); - // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the - // fresh generation rides the same ext-state write the persist makes (persistBook() -> - // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs - // (the caller decides); a pure-organizational verb passes false and leaves the counter be, - // so a rename/activate does not needlessly refresh live instances. - if (bumpGeneration) g_session->bumpBankGeneration(); - const bool persisted = persistBook(); - if (persisted) - Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); - else - Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point -} - -void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { - g_session = session; // shared with the Design View family; same live session - - g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate, - "create bank"); - g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename, - "rename bank"); - g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete, - "delete bank"); - g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate, - "evacuate bank to pool"); - g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext, - "activate next bank (cycle)"); - g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool, - "activate pool"); - g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel, - "move selected samples to bank"); - g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, - "copy selected samples to bank"); - g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, - "remove selected samples"); - g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, - "toggle pool full-height"); - g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, - "toggle banks full-height"); - // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends - // the confirm-and-delete step behind this SAME forever-stable id). - g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder, - "prune bank folder"); -} - -bool bankHandleCommand(int command) { - if (command == 0 || !g_session) return false; - - if (command == g_cmdBankCreate) { doBankCreate(); return true; } - if (command == g_cmdBankRename) { doBankRename(); return true; } - if (command == g_cmdBankDelete) { doBankDelete(); return true; } - if (command == g_cmdBankEvacuate) { doBankEvacuate(); return true; } - if (command == g_cmdBankActivateNext) { doBankActivateNext(); return true; } - if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } - if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } - if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } - if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } - if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } - if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } - if (command == g_cmdBankPruneFolder) { doBankPruneFolder(); return true; } - - return false; // not ours — caller's hookcommand keeps looking -} - -int bankPruneCommandId() { return g_cmdBankPruneFolder; } - -void bankUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each - // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). - rec->Register("-gaccel", (void*)&g_accelBankPruneFolder); - rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder)); - rec->Register("-gaccel", (void*)&g_accelBankBanksFull); - rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); - rec->Register("-gaccel", (void*)&g_accelBankPoolFull); - rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull)); - rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); - rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel)); - rec->Register("-gaccel", (void*)&g_accelBankCopySel); - rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel)); - rec->Register("-gaccel", (void*)&g_accelBankMoveSel); - rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel)); - rec->Register("-gaccel", (void*)&g_accelBankActivatePool); - rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool)); - rec->Register("-gaccel", (void*)&g_accelBankActivateNext); - rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext)); - rec->Register("-gaccel", (void*)&g_accelBankEvacuate); - rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate)); - rec->Register("-gaccel", (void*)&g_accelBankDelete); - rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete)); - rec->Register("-gaccel", (void*)&g_accelBankRename); - rec->Register("-command_id", (void*)channelIdFor(kIdBankRename)); - rec->Register("-gaccel", (void*)&g_accelBankCreate); - rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate)); - - // g_session is shared with the Design View family; designViewUnregisterActions - // also nulls it. Nulling twice is harmless. Leave it to whichever runs last. - g_session = nullptr; -} - -} // namespace reasampler diff --git a/src/actions.h b/src/actions.h deleted file mode 100644 index 936b381..0000000 --- a/src/actions.h +++ /dev/null @@ -1,93 +0,0 @@ -#pragma once -#include "core/namespaces.h" -// actions — the Design View action family (Phase D4). Registers the bindable -// actions that drive the mode workflow and wires them end-to-end: toggle/activate -// a mode, tag/untag/show-both the current track selection. Each action mutates the -// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies -// the active mode through the view shell (D2) so the change takes effect immediately. -// -// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This -// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. -// -// Split out of main.cpp (rather than inlined there) to match CONTEXT.md's planned -// `actions` module and keep main.cpp's entrypoint focused on API-pointer ownership -// and lifecycle. The reapply-on-open glue stays in main.cpp (it owns the timer that -// drives persist.poll()); this module only registers and services the actions. - -// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct -// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real -// definition. Declared before the namespace so it is the global type, not a -// namespace-local shadow. -struct reaper_plugin_info_t; - -namespace reasampler { - -class ReaSamplerSession; - -// Registers the Design View action family against `rec` (command_id + gaccel + -// hookcommand-routing is owned by the caller's single hookcommand). `session` is the -// live session the actions mutate; it must outlive the registration. Idempotent is -// NOT promised — call exactly once at load, mirror-unregister once at unload. -void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); - -// Services one fired command. Returns true iff `command` is one of this module's -// action ids (and it was handled); false otherwise so the caller's hookcommand keeps -// looking (per the contract: claim only our own ids). Safe to call for any command. -bool designViewHandleCommand(int command); - -// Mirror-unregisters everything designViewRegisterActions registered, with the -// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. -void designViewUnregisterActions(reaper_plugin_info_t* rec); - -// --- Multi-bank action family (Phase B3) ----------------------------------- -// The bindable action set that drives the multi-bank workflow: create / rename / -// delete / evacuate a bank, activate a bank (direct pool/design-free + cycle), move / -// copy the panel's selected samples into a bank, and the two vertical-split -// full-height toggles. Every mutating action drives the B1 model on -// g_session.book() and persists via g_session.saveToActiveProject() so the change -// travels with the .rpp; the toggles flip the B4-rendered layout bit on the panel. -// -// Same registration/routing/unload contract as the Design View family above and the -// same shared g_session. Kept a distinct trio (not folded into the Design View one) -// because the two families are orthogonal pillars — but they share the single -// hookcommand main.cpp owns; each family's Handle claims only its own ids. - -// Registers the multi-bank family against `rec`. `session` is the live session (must -// outlive registration). Call exactly once at load. Shares g_session with the Design -// View family — pass the SAME session pointer. -void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); - -// Services one fired command for the multi-bank family. True iff it was one of this -// family's ids (and handled); false otherwise so the caller's hookcommand keeps -// looking. Safe for any command. -bool bankHandleCommand(int command); - -// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on -// rec==nullptr (before g_session is torn down). -void bankUnregisterActions(reaper_plugin_info_t* rec); - -// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before -// registration. The bank_panel prune button fires the action THROUGH this id via -// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session -// directly) so the panel affordance and the bindable action share one guarded code path. -int bankPruneCommandId(); - -// Persists a completed bank-index verb as a single REAPER undo point (R-B). -// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG -// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops -// and the block is closed with an empty label + zero flag (REAPER discards it). Callers -// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate -// name, un-deletable pool, etc.) must return before reaching here so no empty undo -// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook(). -// -// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live -// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left, -// arrived, or dropped out of a bank an instance may reference). Leave it false (the default) -// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no -// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when -// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the -// same ext-state write and undo captures the pre/post generation with the rest of the blob. -void persistBankOp(const char* label, bool bumpGeneration = false); - -} // namespace reasampler diff --git a/src/app/main.cpp b/src/app/main.cpp index 7200eaa..85f08f4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -28,11 +28,12 @@ #include #include -#include "actions.h" #include "core/capture/render_settings.h" // captureActionTable #include "core/version/app_version.h" // channelCommandId / channelActionName / appVersion #include "ingest.h" #include "persist.h" +#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) +#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) #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 diff --git a/src/core/model/provenance.h b/src/core/model/provenance.h index cd6255d..14c4cfb 100644 --- a/src/core/model/provenance.h +++ b/src/core/model/provenance.h @@ -2,7 +2,7 @@ // provenance — the REAPER-free core behind Milestone 10 (re-capture from source). // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp) +// vendor/ includes. Standard library only. The shell (main.cpp / the action families) // gathers the raw inputs from REAPER — the source item media-file names, the // source track FX-chain identity (names / GUIDs / enabled flags), the exact // capture range, scope, tail — and hands plain strings/values here. This module diff --git a/src/core/version/app_version.h b/src/core/version/app_version.h index 603dcf1..8ef85bf 100644 --- a/src/core/version/app_version.h +++ b/src/core/version/app_version.h @@ -140,7 +140,7 @@ const std::string& vstPluginName(); // --- Channel-qualified action id / name builders ------------------------------------ // -// The two composition helpers every action-registering shell (main.cpp, actions.cpp) +// The two composition helpers every action-registering shell (main.cpp, action_registry) // funnels through, so command ids and Actions-list names are qualified IDENTICALLY on // every channel from ONE definition — no shell re-implements the concatenation. // diff --git a/src/ingest.cpp b/src/ingest.cpp index bfc6d80..8bf93da 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -16,7 +16,6 @@ #include #include -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path) #include "core/version/app_version.h" // channelCommandId / channelActionName #include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode #include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex @@ -435,7 +434,7 @@ void doImportFromMediaExplorer() { // The generation is bumped only when something NEW landed (a dedup collapse mutated nothing, // so it needs neither a bump nor a persist to resolve — the sample is already in ext-state). // If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so - // REAPER discards the undo entry (the house pattern from actions.cpp). importFileIntoActiveBank + // REAPER discards the undo entry (the house pattern from persistBankOp). importFileIntoActiveBank // already refuses on an unsaved project, so in practice the persist here succeeds. Undo_BeginBlock2(nullptr); bool persisted = true; // true when nothing needed persisting (dedup) — governs the label path @@ -508,7 +507,7 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { // One undo point for the whole drop, opened only if a NEW index entry was created (a // drop that only re-hit existing content mutated nothing on the index). // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero - // flag so REAPER discards the undo entry (house pattern from actions.cpp). + // flag so REAPER discards the undo entry (house pattern from persistBankOp). if (importedNew > 0) { Undo_BeginBlock2(nullptr); // S9: one coalesced generation bump for the whole drop (>=1 new sample landed) so diff --git a/src/shell/actions/action_registry.cpp b/src/shell/actions/action_registry.cpp new file mode 100644 index 0000000..1b7c1ec --- /dev/null +++ b/src/shell/actions/action_registry.cpp @@ -0,0 +1,49 @@ +// action_registry.cpp — shared registration plumbing (Q-W4 split of actions.cpp). +// See action_registry.h. Needs no REAPER API pointers: rec->Register is a member +// call on the dispatch struct REAPER hands the entry point. + +#include "shell/actions/action_registry.h" + +#include +#include + +#include "core/version/app_version.h" // channelCommandId / channelActionName + +namespace reasampler { + +namespace { + +using version::channelActionName; +using version::channelCommandId; + +// Durable store of composed, channel-qualified strings (ids + labels). A std::deque +// never invalidates references on push_back, so a c_str() handed to REAPER (a +// command_id at register, a gaccel desc for its lifetime) stays valid until process +// exit. Memoized by suffix so register and the mirror-unregister get the SAME id +// pointer for a given action. +std::deque g_strStore; + +} // namespace + +const char* channelIdFor(const char* suffix) { + const std::string composed = channelCommandId(suffix); + for (const std::string& s : g_strStore) + if (s == composed) return s.c_str(); + g_strStore.push_back(composed); + return g_strStore.back().c_str(); +} + +int registerAction(reaper_plugin_info_t* rec, const char* suffix, + gaccel_register_t& accel, const char* phrase) { + const char* id = channelIdFor(suffix); + const int cmd = rec->Register("command_id", (void*)id); + if (cmd) { + g_strStore.push_back(channelActionName(phrase)); + accel.accel.cmd = cmd; + accel.desc = g_strStore.back().c_str(); + rec->Register("gaccel", (void*)&accel); + } + return cmd; +} + +} // namespace reasampler diff --git a/src/shell/actions/action_registry.h b/src/shell/actions/action_registry.h new file mode 100644 index 0000000..fd7f4ce --- /dev/null +++ b/src/shell/actions/action_registry.h @@ -0,0 +1,29 @@ +#pragma once +// action_registry — shared registration plumbing for the bindable action families +// (Q-W4 split of actions.cpp). Owns the durable interned-string store both the +// Design View and multi-bank families register through, so a composed command id +// keeps ONE stable pointer from register to the mirror-unregister, and the +// register-a-command_id-then-gaccel sequence has one implementation. +// +// Includes reaper_plugin.h (gaccel_register_t / reaper_plugin_info_t full defs); +// only the action-family TUs include this header. Q-W6's registration table +// subsumes this helper when the hand-written blocks become data. + +#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t + +namespace reasampler { + +// Returns the channel-qualified command id for `suffix`, interning it once for the +// process lifetime. Called by BOTH registerAction and each family's unregister path, +// so a '-command_id' presents the IDENTICAL string pointer registered earlier. +const char* channelIdFor(const char* suffix); + +// Mints a command id from a channel-qualified SUFFIX and registers its gaccel +// (Actions-list entry with a channel-qualified label PHRASE). Returns the command id +// (0 on failure). Both the composed id and label are interned durably — REAPER holds +// the desc pointer, and the id must survive to the mirror-unregister. The gaccel +// storage itself is caller-owned (file-scope in the family TU). +int registerAction(reaper_plugin_info_t* rec, const char* suffix, + gaccel_register_t& accel, const char* phrase); + +} // namespace reasampler diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp new file mode 100644 index 0000000..67ddf94 --- /dev/null +++ b/src/shell/actions/bank_actions.cpp @@ -0,0 +1,366 @@ +// bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of +// actions.cpp). See bank_actions.h. +// +// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptText), +// name resolution, and console feedback — over the promptless bankOp* inner verbs +// homed in panel_bank_ops (model op + persistBankOp, one bank op = one Ctrl-Z). The +// book's rules (pool privileges, collapse-by-hash, active-fallback-to-pool) all live +// in bank_book; these handlers only drive the verbs and react to the boolean. +// +// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index +// return a reference INTO the book's internal vector, which a create/delete can +// reallocate. No handler here caches a BankModel& (or a Bank*) across a structural +// mutation — each resolves ids to strings up front and re-resolves after any +// create/delete. Move/copy pass ids (not references) straight to the verbs. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers (CLAUDE.md §contract). +// The action ids are minted from FOREVER-STABLE strings; user keybindings key off +// them, so they must never change after ship. + +#include "shell/actions/bank_actions.h" + +#include +#include + +#include "shell/actions/action_registry.h" // channelIdFor / registerAction (shared plumbing) +#include "shell/actions/prune_action.h" // doBankPruneFolder — the guarded prune body + +#include "core/model/bank_book.h" // BankBook, nextBankId, kPoolBankId (B1) +#include "persist.h" // ReaSamplerSession (owns book()) +#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptText + selection seam +#include "shell/panel/panel_layout.h" // full-height toggles (B3) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_ShowMessageBox +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is +// prepended at register via channelCommandId (as with the Design View family) — stable +// rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. +// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly +// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). +constexpr const char* kIdBankCreate = "BANK_CREATE"; +constexpr const char* kIdBankRename = "BANK_RENAME"; +constexpr const char* kIdBankDelete = "BANK_DELETE"; +constexpr const char* kIdBankEvacuate = "BANK_EVACUATE"; +constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT"; +constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL"; +constexpr const char* kIdBankMoveSel = "BANK_MOVE_SELECTED"; +constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; +constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; +constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; +constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; +// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so +// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion), +// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. +constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; + +// The live session the actions read (name resolution, member counts, prune). The +// mutations themselves run through the bankOp* verbs, which resolve the same session +// via the panel seam. Set once by bankRegisterActions; not owned here. +ReaSamplerSession* g_session = nullptr; + +int g_cmdBankCreate = 0; +int g_cmdBankRename = 0; +int g_cmdBankDelete = 0; +int g_cmdBankEvacuate = 0; +int g_cmdBankActivateNext = 0; +int g_cmdBankActivatePool = 0; +int g_cmdBankMoveSel = 0; +int g_cmdBankCopySel = 0; +int g_cmdBankRemoveSel = 0; +int g_cmdBankPoolFull = 0; +int g_cmdBankBanksFull = 0; +int g_cmdBankPruneFolder = 0; + +// gaccel storage must outlive registration — REAPER holds each pointer until we +// mirror-unregister it. One per action. +gaccel_register_t g_accelBankCreate{}; +gaccel_register_t g_accelBankRename{}; +gaccel_register_t g_accelBankDelete{}; +gaccel_register_t g_accelBankEvacuate{}; +gaccel_register_t g_accelBankActivateNext{}; +gaccel_register_t g_accelBankActivatePool{}; +gaccel_register_t g_accelBankMoveSel{}; +gaccel_register_t g_accelBankCopySel{}; +gaccel_register_t g_accelBankRemoveSel{}; +gaccel_register_t g_accelBankPoolFull{}; +gaccel_register_t g_accelBankBanksFull{}; +gaccel_register_t g_accelBankPruneFolder{}; + +// Resolves a user-typed bank reference (a display name) to a bank id, scanning the +// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool. +// Returns "" when no bank carries that name. Kept in the action layer (not the model) +// — it is UI name-resolution, not a model rule. First-match is unambiguous BY +// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive), +// so at most one bank can carry a given name — no duplicate can shadow another here. +std::string bankIdByDisplayName(const std::string& name) { + for (const Bank& b : g_session->book().banks()) + if (b.displayName == name) return b.id; + return {}; +} + +// -- Action bodies (thin UX skins over the bankOp* verbs) ------------------- + +// Create a named bank: prompt for a display name; the verb mints a stable GUID id, +// creates it in the model, persists. The new bank is NOT auto-activated (create and +// activate are distinct acts — mirrors capture/placement separation). The model +// rejects a duplicate display name (trimmed + case-insensitive, incl. "Pool"); the +// create then fails and the user is told the name is taken. +void doBankCreate() { + std::string name; + if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; + if (bankOpCreate(name).empty()) { + ShowConsoleMsg( + ("ReaSampler: could not create bank \"" + name + + "\" (a bank with that name already exists).\n") + .c_str()); + } +} + +// Rename a bank: prompt for which bank (by current display name) and the new name. +// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form +// self-contained; the panel renames in place on a tab. +void doBankRename() { + std::string which; + if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "", + which)) + return; + const std::string id = bankIdByDisplayName(which); + if (id.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); + return; + } + std::string newName; + if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return; + if (!bankOpRename(id, newName)) { + // The verb rejects the pool (un-renamable) or a name already used by another + // bank (unique display names, trimmed + case-insensitive). + ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " + "or another bank already uses that name).\n"); + } +} + +// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: +// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as +// the alternative before dropping them (a plain delete orphans those members' files +// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer +// panel confirm (naming evacuate inline, with a one-click evacuate) lives in the panel. +void doBankDelete() { + std::string which; + if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return; + const std::string id = bankIdByDisplayName(which); + if (id.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); + return; + } + // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here, + // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading + // "delete anyway?" prompt for an operation the model will refuse regardless. + if (id == kPoolBankId) { + ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n"); + return; + } + // Read member count BEFORE deleting (the Bank* is invalidated by the delete; we do + // not cache it — resolve size to an int up front). + const Bank* b = g_session->book().bank(id); + if (!b) return; // race-safe: id resolved above but re-check + const std::size_t members = b->index.size(); + if (members > 0) { + const std::string msg = + "\"" + which + "\" holds " + std::to_string(members) + + (members == 1 ? " sample" : " samples") + + ".\n\nDeleting drops them from every bank (their files are NOT deleted, " + "but no bank will reference them until prune).\n\nTo keep the samples, " + "cancel and Evacuate the bank to the pool first.\n\nDelete anyway?"; + const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4); + if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) + } + // S9: bump only when the deleted bank held samples — dropping them changes what a live + // instance referencing one could play. Deleting an EMPTY bank is purely organizational. + if (!bankOpDelete(id, /*bumpGeneration=*/members > 0)) { + ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); + } +} + +// Evacuate a named bank: move every member back to the pool (index-only, collapse by +// hash), leaving the bank empty. The pool is un-evacuable (the verb rejects it). The +// intended "keep the samples" companion to delete. +void doBankEvacuate() { + std::string which; + if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", + which)) + return; + const std::string id = bankIdByDisplayName(which); + if (id.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); + return; + } + if (!bankOpEvacuate(id)) { + ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " + "destination, not a source).\n"); + } +} + +// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), +// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the +// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and +// never touches the timeline. The verb persists so the active id travels with the .rpp. +void doBankActivateNext() { + std::vector ids; + ids.reserve(g_session->book().size()); + for (const Bank& b : g_session->book().banks()) ids.push_back(b.id); + const std::string target = nextBankId(ids, g_session->book().activeBankId()); + if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) + bankOpActivate(target); +} + +// Activate the pool directly (the common "back to the default target" jump). Bindable +// direct-by-id form; a general activate-bank-by-name/menu is a panel affordance. +void doBankActivatePool() { + bankOpActivate(kPoolBankId); +} + +// Move or copy the panel's selected samples into a named destination bank (prompted +// by display name). The SOURCE is the bank the selection lives in — the focused +// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical +// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are +// index-only (files never relocate); the verb owns the verb-aware no-op guardrail and +// destination collapse-by-hash. The panel's "move to bank" menu drives the same verb +// with a menu-chosen destination — this bindable form is the same operation with a +// text-prompt destination. +void doBankTransferSelected(bool copy) { + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) { + ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to " + "move/copy.\n"); + return; + } + const char* verb = copy ? "copy" : "move"; + const std::string title = std::string("ReaSampler: ") + verb + " selected samples"; + std::string destName; + if (!promptText(title.c_str(), "Destination bank:", "", destName)) return; + const std::string destId = bankIdByDisplayName(destName); + if (destId.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); + return; + } + // Source = the bank the selection lives in (the focused region's displayed bank). + const std::string srcId = bankPanelSelectedSourceBankId(); + if (srcId == destId) { + ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); + return; + } + bankOpTransfer(selected, srcId, destId, copy); +} + +// Remove the panel's selected samples from the SOURCE bank (the focused region's +// displayed bank — same source as move/copy). Index-only and non-destructive to the +// file (orphaned until Phase R prune); silent, with the batched undo as recovery — +// see bankOpRemove for the full contract. +void doBankRemoveSelected() { + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) { + ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n"); + return; + } + const std::string srcId = bankPanelSelectedSourceBankId(); + if (g_session->book().bank(srcId) == nullptr) { + ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); + return; + } + bankOpRemove(selected, srcId); +} + +} // namespace + +void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { + g_session = session; // same live session as the Design View family + + g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate, + "create bank"); + g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename, + "rename bank"); + g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete, + "delete bank"); + g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate, + "evacuate bank to pool"); + g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext, + "activate next bank (cycle)"); + g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool, + "activate pool"); + g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel, + "move selected samples to bank"); + g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, + "copy selected samples to bank"); + g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, + "remove selected samples"); + g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, + "toggle pool full-height"); + g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, + "toggle banks full-height"); + // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends + // the confirm-and-delete step behind this SAME forever-stable id). + g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder, + "prune bank folder"); +} + +bool bankHandleCommand(int command) { + if (command == 0 || !g_session) return false; + + if (command == g_cmdBankCreate) { doBankCreate(); return true; } + if (command == g_cmdBankRename) { doBankRename(); return true; } + if (command == g_cmdBankDelete) { doBankDelete(); return true; } + if (command == g_cmdBankEvacuate) { doBankEvacuate(); return true; } + if (command == g_cmdBankActivateNext) { doBankActivateNext(); return true; } + if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } + if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } + if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } + if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } + if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } + if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } + if (command == g_cmdBankPruneFolder) { doBankPruneFolder(*g_session); return true; } + + return false; // not ours — caller's hookcommand keeps looking +} + +int bankPruneCommandId() { return g_cmdBankPruneFolder; } + +void bankUnregisterActions(reaper_plugin_info_t* rec) { + // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each + // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). + rec->Register("-gaccel", (void*)&g_accelBankPruneFolder); + rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder)); + rec->Register("-gaccel", (void*)&g_accelBankBanksFull); + rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); + rec->Register("-gaccel", (void*)&g_accelBankPoolFull); + rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull)); + rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel)); + rec->Register("-gaccel", (void*)&g_accelBankCopySel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel)); + rec->Register("-gaccel", (void*)&g_accelBankMoveSel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel)); + rec->Register("-gaccel", (void*)&g_accelBankActivatePool); + rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool)); + rec->Register("-gaccel", (void*)&g_accelBankActivateNext); + rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext)); + rec->Register("-gaccel", (void*)&g_accelBankEvacuate); + rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate)); + rec->Register("-gaccel", (void*)&g_accelBankDelete); + rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete)); + rec->Register("-gaccel", (void*)&g_accelBankRename); + rec->Register("-command_id", (void*)channelIdFor(kIdBankRename)); + rec->Register("-gaccel", (void*)&g_accelBankCreate); + rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate)); + + g_session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/actions/bank_actions.h b/src/shell/actions/bank_actions.h new file mode 100644 index 0000000..f8221e3 --- /dev/null +++ b/src/shell/actions/bank_actions.h @@ -0,0 +1,45 @@ +#pragma once +// bank_actions — the multi-bank bindable action family (Phase B3; Q-W4 split of +// actions.h). The bindable action set that drives the multi-bank workflow: create / +// rename / delete / evacuate a bank, activate a bank (direct pool + cycle), move / +// copy / remove the panel's selected samples, the two vertical-split full-height +// toggles, and the Phase R prune action's registration + dispatch (its guarded body +// lives in prune_action). Q-W4 dedupe: every mutating handler here is a THIN UX skin +// (text prompts + console messages) over the promptless bankOp* verbs homed in +// panel_bank_ops — one implementation home for each mutation, two UX skins (this +// family prompts for which bank; the panel acts on a clicked tab). +// +// Same registration/routing/unload contract as the Design View family +// (design_view_actions); both share main.cpp's single hookcommand, and each family's +// Handle claims only its own ids. This header is SDK-free. + +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this +// header stays SDK-free; the .cpp includes the real definition. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the multi-bank family against `rec`. `session` is the live session (must +// outlive registration). Call exactly once at load — pass the SAME session pointer +// the Design View family receives. +void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Services one fired command for the multi-bank family. True iff it was one of this +// family's ids (and handled); false otherwise so the caller's hookcommand keeps +// looking. Safe for any command. +bool bankHandleCommand(int command); + +// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on +// rec==nullptr (before the session is torn down). +void bankUnregisterActions(reaper_plugin_info_t* rec); + +// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 +// before registration. The bank_panel prune button fires the action THROUGH this id +// via Main_OnCommand (fork R-E: the button dispatches the command, it does not call +// the session directly) so the panel affordance and the bindable action share one +// guarded code path. +int bankPruneCommandId(); + +} // namespace reasampler diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp new file mode 100644 index 0000000..11a66b8 --- /dev/null +++ b/src/shell/actions/design_view_actions.cpp @@ -0,0 +1,383 @@ +// design_view_actions.cpp — the Design View action family (Phase D4; Q-W4 split of +// actions.cpp). See design_view_actions.h. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers +// (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the +// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off +// them, so they must never change after ship. +// +// Each action: +// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the +// active mode via toggle/activate) — the pure D1 state, +// 2. reapplies the active mode through the D2 view shell (applyMode) so the change +// takes visible effect immediately (tagging a track into Design while in Arrange +// parks it right away; a mode change re-partitions and re-parks in one step). +// +// Selection-driven mutations iterate the CURRENT REAPER track selection +// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct: +// the master is never tagged) and resolve each track to its canonical GUID key via +// the shared guidString helper, so the keys match exactly what the D2 shell / view +// tree key on (the cross-module key contract). + +#include "shell/actions/design_view_actions.h" + +#include +#include + +#include "shell/actions/action_registry.h" // channelIdFor / registerAction (shared plumbing) + +#include "core/view/lane_keys.h" // view::isOnManualLane — the single managed/manual predicate +#include "core/view/view_mode_model.h" +#include "persist.h" // ReaSamplerSession (owns view() model) +#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) +#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key +#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint +#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountSelectedTracks +#define REAPERAPI_WANT_GetSelectedTrack +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItemTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_Main_SaveProject +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using view::isOnManualLane; + +namespace { + +// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended +// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped +// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family +// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a +// persistent command id user keybindings key off — NEVER change a shipped suffix after ship. +constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; +constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; +constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; +constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; +constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; +constexpr const char* kIdUntag = "VIEW_UNTAG"; +constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; +// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same +// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. +constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; +constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; +constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS"; + +// The live session the actions mutate. Set once by designViewRegisterActions and +// read by the hookcommand handler. Not owned here (main.cpp owns g_session). +ReaSamplerSession* g_session = nullptr; + +// Minted command ids (0 until registration succeeds). Compared in the handler. +int g_cmdToggleMode = 0; +int g_cmdActivateArrange = 0; +int g_cmdActivateDesign = 0; +int g_cmdTagDesign = 0; +int g_cmdTagArrange = 0; +int g_cmdUntag = 0; +int g_cmdShowBoth = 0; +int g_cmdMoveItemsDesign = 0; +int g_cmdMoveItemsArrange = 0; +int g_cmdUntagItems = 0; + +// gaccel storage must outlive registration — REAPER holds each pointer until we +// mirror-unregister it. One per action. +gaccel_register_t g_accelToggleMode{}; +gaccel_register_t g_accelActivateArrange{}; +gaccel_register_t g_accelActivateDesign{}; +gaccel_register_t g_accelTagDesign{}; +gaccel_register_t g_accelTagArrange{}; +gaccel_register_t g_accelUntag{}; +gaccel_register_t g_accelShowBoth{}; +gaccel_register_t g_accelMoveItemsDesign{}; +gaccel_register_t g_accelMoveItemsArrange{}; +gaccel_register_t g_accelUntagItems{}; + +// Collects the canonical GUID keys of the current track selection. Empty if nothing +// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is +// exactly right — the master is never a tagged leaf. +std::vector selectedTrackGuids() { + std::vector guids; + const int n = CountSelectedTracks(nullptr); // nullptr = active project + guids.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaTrack* tr = GetSelectedTrack(nullptr, i); + if (!tr) continue; + std::string g = guidString(tr); + if (!g.empty()) guids.push_back(std::move(g)); + } + return guids; +} + +// Reapplies the model's CURRENT active mode to the active project so a membership +// mutation takes visible effect immediately (park/unpark/re-derive parents). Called +// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project. +void reapplyActiveMode() { + applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); +} + +// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to +// decide whether an item's lane name is meaningful for the manual-lane read. +constexpr int kFreeModeFixedLanes = 2; + +// Collects the current media-item selection as the pure decision's input: each selected +// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned). +// The manual-lane read follows the shared pure predicate exactly as the shell's readers +// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal +// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped. +// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). +std::vector selectedRetagItems() { + std::vector items; + const int n = CountSelectedMediaItems(nullptr); // nullptr = active project + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + std::string g = itemGuid(it); + if (g.empty()) continue; + + MediaTrack* tr = GetMediaItemTrack(it); + const bool fixedLane = + tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; + // Only read the lane name on a fixed-lane track; the pure predicate handles the + // normal-track case (returns false) so we pass an empty name and skip the read. + const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; + items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); + } + return items; +} + +// Persists both the bank and the Design-View model to the active project's ext +// state. Called after every state-changing Design View action so the view model +// is not lost across save/close/reopen. Marking the project dirty is correct — +// a Design View mutation is a project-level change the user should be prompted +// to save. +// +// When the membership index is non-empty AND the project is unsaved, we prompt +// the user to Save-As before persisting — mirroring the flow capture uses. +// Gate: if membership is empty (no tracks tagged), skip the prompt entirely; +// saveToActiveProject will no-op for an unsaved project, which is correct. +// +// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and +// blocks until the user dismisses it. The blocking behaviour and dialog +// appearance can only be confirmed in a running REAPER (same caveat as capture). +void persistViewState() { + if (!g_session->view().membership().empty()) { + // At least one track is tagged — worth persisting. Check whether the + // project is saved and, if not, prompt Save-As so saveToActiveProject + // can write ext state. Mirrors capture's readRppPath idiom exactly. + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (proj) { + auto readRppPath = [&]() -> std::string { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + return std::string(buf.data()); + }; + + if (readRppPath().empty()) { + // Project is unsaved — prompt Save-As. + Main_SaveProject(proj, true); + // Re-read: still empty means the user cancelled. + if (readRppPath().empty()) { + ShowConsoleMsg( + "ReaSampler: Design View state will not persist until " + "the project is saved.\n"); + // The in-session tag state is left as-is — the mode change + // already applied and remains valid for this session. + return; + } + } + } + } + g_session->saveToActiveProject(); +} + +// -- Action bodies --------------------------------------------------------- + +// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two +// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode +// itself sets the model's active mode, so we only compute the target and apply. +void doToggleMode() { + const std::string target = + nextModeId(g_session->view().modes(), g_session->view().activeModeId()); + if (target.empty()) return; // no modes to cycle to (degenerate) + applyMode(g_session->view(), target, nullptr); + persistViewState(); + bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately +} + +// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if +// the id is unregistered, so an absent mode fails safe. +void doActivateMode(const std::string& modeId) { + applyMode(g_session->view(), modeId, nullptr); + persistViewState(); + bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately +} + +// Tag the selection's leaves into `modeId`, then reapply so the change is immediate. +// tag() replaces any prior single-mode membership (a leaf lives in one mode; the +// cross-mode case is show-both), matching the D1 contract. +void doTag(const std::string& modeId) { + for (const std::string& g : selectedTrackGuids()) + g_session->view().membership().tag(g, modeId); + reapplyActiveMode(); + persistViewState(); +} + +// Untag the selection entirely (return each to the Arrange default). This is the +// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the +// absence of a tag), so the two actions are the same act by definition. +void doUntag() { + for (const std::string& g : selectedTrackGuids()) + g_session->view().membership().untag(g); + reapplyActiveMode(); + persistViewState(); +} + +// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each +// track and flip it independently (a mixed selection converges toward "all on" then +// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a +// multi-selection). show-both leaves are never parked (D1), so reapply reflects the +// change immediately. +void doShowBoth() { + MembershipIndex& m = g_session->view().membership(); + for (const std::string& g : selectedTrackGuids()) + m.setShowBoth(g, !m.isShowBoth(g)); + reapplyActiveMode(); + persistViewState(); +} + +// -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- +// +// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default), +// then re-drive the minting + apply path so each moved item lands on its target mode's +// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag +// decides which selected items to retag (manual-lane items are EXEMPT — never retagged, +// never re-laned), upholding the managed-lanes-only invariant even under this explicit +// user action. The whole structural act is wrapped in ONE Undo block with a descriptive +// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it). +// +// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which +// must NOT sit inside the Undo block, so we close the block first, then persist — the same +// separation the track actions rely on (they persist outside applyMode's own block). +void doMoveItems(const std::string& targetMode) { + const std::vector selected = selectedRetagItems(); + const std::vector ops = planItemRetag(selected, targetMode); + if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty + + MembershipIndex& membership = g_session->view().membership(); + + Undo_BeginBlock2(nullptr); + // Apply the pure decision's membership writes: tag into targetMode, or untag. + for (const ItemRetagOp& op : ops) { + if (op.untag) membership.untag(op.guid); + else membership.tag(op.guid, op.modeId); + } + // Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track + // whose items now span modes and assign each moved item to its mode's managed lane, + // then reassert the active mode's lane visibility. Manual lanes stay untouched + // (mintManagedLanes reports their items exempt and never mints over them). + mintManagedLanes(g_session->view(), nullptr); + reapplyActiveMode(); + + const std::string label = + targetMode.empty() + ? std::string("ReaSampler: untag selected items") + : std::string("ReaSampler: move selected items -> ") + targetMode; + Undo_EndBlock2(nullptr, label.c_str(), -1); + + persistViewState(); +} + +} // namespace + +void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { + g_session = session; + + // command_id -> gaccel for each. The single hookcommand that routes these lives + // in main.cpp (one hook per extension); designViewHandleCommand services them. + g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, + "toggle Design View mode"); + g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, + "activate mode Arrange"); + g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign, + "activate mode Design"); + g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign, + "tag selected tracks -> Design"); + g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange, + "tag selected tracks -> Arrange"); + g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag, + "untag selected tracks"); + g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, + "show both for selected tracks"); + + // Item-level mode moves (D2 W3-B): the item analog of the track tag family. + g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, + "move selected items -> Design"); + g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, + "move selected items -> Arrange"); + g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, + "untag selected items"); +} + +bool designViewHandleCommand(int command) { + if (command == 0 || !g_session) return false; + + if (command == g_cmdToggleMode) { doToggleMode(); return true; } + if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; } + if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; } + if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; } + // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag). + if (command == g_cmdTagArrange) { doUntag(); return true; } + if (command == g_cmdUntag) { doUntag(); return true; } + if (command == g_cmdShowBoth) { doShowBoth(); return true; } + + // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an + // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above. + if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; } + if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; } + if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; } + + return false; // not ours — caller's hookcommand keeps looking +} + +void designViewUnregisterActions(reaper_plugin_info_t* rec) { + // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. + // gaccel first, then the command_id string (reverse of registration order — the item + // moves registered last, so they tear down first). + // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor + // returns the memoized pointer registered above), so the unregister matches exactly. + rec->Register("-gaccel", (void*)&g_accelUntagItems); + rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); + rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange)); + rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign)); + rec->Register("-gaccel", (void*)&g_accelShowBoth); + rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth)); + rec->Register("-gaccel", (void*)&g_accelUntag); + rec->Register("-command_id", (void*)channelIdFor(kIdUntag)); + rec->Register("-gaccel", (void*)&g_accelTagArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange)); + rec->Register("-gaccel", (void*)&g_accelTagDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign)); + rec->Register("-gaccel", (void*)&g_accelActivateDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign)); + rec->Register("-gaccel", (void*)&g_accelActivateArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange)); + rec->Register("-gaccel", (void*)&g_accelToggleMode); + rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode)); + + g_session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/actions/design_view_actions.h b/src/shell/actions/design_view_actions.h new file mode 100644 index 0000000..4006516 --- /dev/null +++ b/src/shell/actions/design_view_actions.h @@ -0,0 +1,38 @@ +#pragma once +// design_view_actions — the Design View action family (Phase D4; Q-W4 split of +// actions.h). Registers the bindable actions that drive the mode workflow and wires +// them end-to-end: toggle/activate a mode, tag/untag/show-both the current track +// selection, and the item-level mode moves (D2 W3-B). Each action mutates the +// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies +// the active mode through the view shell (D2) so the change takes effect immediately. +// +// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This +// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. + +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct +// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real +// definition. Declared before the namespace so it is the global type, not a +// namespace-local shadow. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the Design View action family against `rec` (command_id + gaccel + +// hookcommand-routing is owned by the caller's single hookcommand). `session` is the +// live session the actions mutate; it must outlive the registration. Idempotent is +// NOT promised — call exactly once at load, mirror-unregister once at unload. +void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Services one fired command. Returns true iff `command` is one of this module's +// action ids (and it was handled); false otherwise so the caller's hookcommand keeps +// looking (per the contract: claim only our own ids). Safe to call for any command. +bool designViewHandleCommand(int command); + +// Mirror-unregisters everything designViewRegisterActions registered, with the +// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. +void designViewUnregisterActions(reaper_plugin_info_t* rec); + +} // namespace reasampler diff --git a/src/shell/actions/prune_action.cpp b/src/shell/actions/prune_action.cpp new file mode 100644 index 0000000..2c73851 --- /dev/null +++ b/src/shell/actions/prune_action.cpp @@ -0,0 +1,102 @@ +// prune_action.cpp — the "Prune bank folder" action body (Phase R3; Q-W4 split of +// actions.cpp). See prune_action.h for the contract this TU preserves. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include "shell/actions/prune_action.h" + +#include +#include + +#include "persist.h" // ReaSamplerSession — pruneDryRun / pruneOrphanSet / pruneReclaim + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_ShowMessageBox +#include "reaper_plugin_functions.h" + +namespace reasampler { + +// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE +// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only), +// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest +// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then +// on explicit Yes delete EXACTLY that set (session.pruneReclaim, which recomputes the +// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback). +// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing. +// +// The full (untruncated) orphan set is captured here for the delete; the dry-run's +// truncated list is only the confirm's readout. No ext-state is written and no undo point +// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project +// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. +void doBankPruneFolder(ReaSamplerSession& session) { + const PruneReport report = session.pruneDryRun(); + + // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the + // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather + // than proceed with degraded protection. Distinct from "no orphans": the user must + // know the prune refused to run and why. + if (report.abortedUnreadableUsage) { + std::string msg = + "ReaSampler prune: ABORTED -- one or more instance usage records could not " + "be read or decoded. Nothing was deleted.\n" + "If the owning instance is still loaded it will republish its record on the " + "next poll tick, clearing the abort. If the instance no longer exists (the " + "key is an orphaned corrupt record), clear it manually via ReaScript:\n" + " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" + "Offending key(s):\n"; + for (const std::string& key : report.offendingUsageKeys) { + msg += " " + key + "\n"; + } + ShowConsoleMsg(msg.c_str()); + return; + } + + if (report.count == 0) { + ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n"); + return; + } + + // The EXACT set the delete will target — full, untruncated, so what the confirm + // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the + // confirm so the confirm and the delete reason about the same enumeration. + const std::vector orphanSet = session.pruneOrphanSet(); + + // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped + // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped. + std::string msg = + "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) + + " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n" + "These files are no longer referenced by any bank and were created by ReaSampler.\n" + "They will be moved to the Recycle Bin on Windows (recoverable), or deleted on " + "other platforms.\n\n"; + for (const std::string& rel : report.orphans) msg += " " + rel + "\n"; + if (report.truncated) { + msg += " ... (" + std::to_string(report.count - report.orphans.size()) + + " more not shown)\n"; + } + msg += "\nReclaim these files now?"; + + const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4); + if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544) + ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n"); + return; + } + + // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). + const PruneDeletionResult del = session.pruneReclaim(orphanSet); + + std::string done = "ReaSampler prune: reclaimed " + + std::to_string(del.reclaimedCount) + " file(s), " + + std::to_string(del.reclaimedBytes) + " bytes" + + (del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + "."; + if (del.skippedCount > 0) { + done += " " + std::to_string(del.skippedCount) + + " file(s) skipped (locked, or changed since the report)."; + } + done += "\n"; + ShowConsoleMsg(done.c_str()); +} + +} // namespace reasampler diff --git a/src/shell/actions/prune_action.h b/src/shell/actions/prune_action.h new file mode 100644 index 0000000..fed17df --- /dev/null +++ b/src/shell/actions/prune_action.h @@ -0,0 +1,22 @@ +#pragma once +// prune_action — the "Prune bank folder" action body (Phase R3; Q-W4 split of +// actions.cpp). This is the SOLE file-deletion action in ReaSampler, isolated in its +// own TU so the deletion authority is one obvious module on the actions side (its +// persist-side counterpart concentrates into prune_fs in Q-W5). Registration and +// hookcommand routing for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay with the +// bank family (bank_actions) — one registration flow, one guarded body here. +// +// Contract (preserve exactly): dry-run first; abort outright on unreadable usage +// records (pS-usage fail-safe); confirm-with-manifest before any deletion; opens NO +// undo point and writes NO ext state (file deletion is not REAPER-undoable). Routes +// to persist's public session API only (pruneDryRun / pruneOrphanSet / pruneReclaim). + +namespace reasampler { + +class ReaSamplerSession; + +// Runs the guarded prune flow against `session`. Called by the bank family's +// hookcommand handler when the BANK_PRUNE_FOLDER action fires. +void doBankPruneFolder(ReaSamplerSession& session); + +} // namespace reasampler diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index e106857..6841ccf 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -331,7 +331,7 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def) // An undo that removes the captured sample also clears the assign_request that named it, // preventing a stale request from pointing at a removed sample. The block uses the house // pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero -// flag) matching the bank-op family in actions.cpp. +// flag) matching the bank-op family (persistBankOp, panel_bank_ops). void RunCaptureItemAssign(ReaSamplerSession& session) { // Reuse the Item-scope def from the capture table (index 0) — same range logic, same diff --git a/src/shell/capture/track_guid.h b/src/shell/capture/track_guid.h index ec28cae..f1fa3d7 100644 --- a/src/shell/capture/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -2,7 +2,7 @@ #include "core/namespaces.h" // track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // string used as a membership-index key. Both the Design View shell (view.cpp) and -// the actions layer (actions.cpp) key membership on this exact string, so the key +// the actions layer (design_view_actions.cpp) key membership on this exact string, so the key // contract lives in a single helper rather than being re-derived (and drifting) at // two call sites (the cross-module key contract flagged in D2 review). // diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index 621eb04..d3d8b3b 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -1,13 +1,15 @@ // panel_bank_ops.cpp — the bank-CRUD + menus seam of the docked bank panel (Q-W2 -// split of bank_panel.cpp; Phase B4/B5). The SINGLE home of the panel-side bank verbs -// (create / rename / delete / evacuate / activate / move / copy / remove) — the owner -// Q-W4 dedupes actions.cpp against — plus the book/bank accessors, the popup menus -// that drive them, and the selection-id / OS-drag path resolvers. +// split of bank_panel.cpp; Phase B4/B5). Since Q-W4 this TU is the ONE implementation +// home of the bank verbs (create / rename / delete / evacuate / activate / move / +// copy / remove): the promptless bankOp* inner verbs (model op + persistBankOp only) +// serve BOTH thin UX skins — the panel's menu handlers here and the bindable +// bank_actions family — plus the book/bank accessors, the popup menus that drive +// them, and the selection-id / OS-drag path resolvers. // -// Each op mutates g_session.book() then persists via persistBankOp() (one bank op = -// one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank BOOK — -// that is the whole point of B4 — but only the index/model + ext-state, never the -// arrange, never a sample file on disk (bank ops are index-only; files stay put — +// Each verb mutates the session's book() then persists via persistBankOp() (one bank +// op = one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank +// BOOK — that is the whole point of B4 — but only the index/model + ext-state, never +// the arrange, never a sample file on disk (bank ops are index-only; files stay put — // CONTEXT.md §Multi-bank). REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL // mutation any Bank*/BankModel& is invalid — resolve fresh, pass ids. // @@ -23,7 +25,6 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_bank_ops.h" -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) #include "persist.h" // ReaSamplerSession — the live session the ops mutate #define REAPERAPI_MINIMAL @@ -33,6 +34,8 @@ #define REAPERAPI_WANT_Main_OnCommand #define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_guidToString +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::panel { @@ -79,55 +82,28 @@ std::vector namedBanks() { return out; } -// --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- +// --- Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs) ------ // -// Each op mutates g_session.book() then persists via persistBankOp(). After a -// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we -// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an -// unsaved project the empty-close discard in persistBankOp ensures no stale state +// Q-W4: each handler here owns only the panel's UX (prompts / confirms / message +// boxes / panel-state nudges / repaint); the model op + persist is the shared +// bankOp* inner verb (defined in the public section below). After a STRUCTURAL +// mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we resolve +// fresh, pass ids, and let the next refreshFingerprint repaint. On an unsaved +// project the empty-close discard in persistBankOp ensures no stale state // survives (matches the capture/B3 quiet-persist idiom). -namespace { - -// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { - std::vector buf(512, '\0'); - std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); - const std::string captions = std::string(caption) + ",separator=\x1f"; - if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), - static_cast(buf.size()))) - return false; - std::string s(buf.data()); - if (s.empty()) return false; - out = std::move(s); - return true; -} - -// Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; - guidToString(&g, buf); - return std::string(buf); -} - -} // namespace - void doCreateBank() { if (!book()) return; std::string name; if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = mintBankId(); - if (!book()->createBank(id, name)) { + const std::string id = bankOpCreate(name); + if (id.empty()) { ShowMessageBox("A bank with that name already exists.", "ReaSampler: create bank", 0); return; } g_panel.shownBankId = id; // show the freshly-created bank g_panel.focusedRegion = Region::Banks; - persistBankOp("ReaSampler: create bank"); invalidatePanel(); } @@ -140,12 +116,11 @@ void doRenameBank(const std::string& bankId) { const std::string current = bk->displayName; // copy before any mutation std::string newName; if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; - if (!book()->renameBank(bankId, newName)) { + if (!bankOpRename(bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); return; } - persistBankOp("ReaSampler: rename bank"); invalidatePanel(); } @@ -177,11 +152,11 @@ void doDeleteBank(const std::string& bankId) { } // r == 6 (Yes) falls through to a plain delete (drops members). } - if (!book()->deleteBank(bankId)) return; // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- // delete path moved/dropped members) — both change what a live instance could play. An - // empty-bank delete is purely organizational, no bump. - persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); + // empty-bank delete is purely organizational, no bump. The ORIGINAL member count decides + // (the No-path evacuated them moments ago, but the membership still changed). + if (!bankOpDelete(bankId, /*bumpGeneration=*/members > 0)) return; // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; @@ -192,79 +167,39 @@ void doEvacuateBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; - if (!book()->evacuate(bankId)) return; - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed + if (!bankOpEvacuate(bankId)) return; invalidatePanel(); } void doActivateBank(const std::string& bankId) { - if (!book()) return; - if (!book()->setActiveBank(bankId)) return; // rejects an unknown id - persistBankOp("ReaSampler: activate bank"); + if (!bankOpActivate(bankId)) return; // rejects an unknown id invalidatePanel(); } } // namespace -// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass -// ids straight to the model op (no BankModel& cached across the loop's mutations). -// -// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): -// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally -// before the dest add collapses on hash), so the index DID mutate — counts. -// * COPY collapse: the source is left intact AND the dest already held the hash, -// so NOTHING changed — a true index no-op. Must NOT open an undo point. -// Hence: copy counts only real gains (Copied); move counts gains OR collapses. +// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Thin panel +// skin over bankOpTransfer (the one-home verb owns the loop, the verb-aware no-op +// guardrail, and the undo-batched persist); this layer clears the stale selection +// and repaints on an actual mutation. void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { - if (!book()) return; - if (sampleIds.empty() || srcBankId == destBankId) return; - if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; - int ok = 0, collapsed = 0; - for (const std::string& sid : sampleIds) { - const TransferResult r = - copy ? book()->copySample(sid, srcBankId, destBankId) - : book()->moveSample(sid, srcBankId, destBankId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedSameBank: break; - } - } - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (!mutated) return; // nothing changed — no persist, no undo point - - const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; - persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed + if (!bankOpTransfer(sampleIds, srcBankId, destBankId, copy)) + return; // nothing changed — no persist, no undo point // The selection indexed into the source; after a move those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } -// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to -// the file: a last-reference remove leaves the file on disk, orphaned until Phase R -// prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent -// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — -// one Ctrl-Z restores the index entry. Ids passed by value — no BankModel& cached -// across the loop's mutations. +// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Thin panel skin +// over bankOpRemove — see the verb for the never-deletes-bytes / silent-remove / +// one-Ctrl-Z contract. Clears the stale selection and repaints on an actual removal. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { - if (!book() || sampleIds.empty()) return; - if (!book()->bank(srcBankId)) return; - - int removed = 0; - for (const std::string& sid : sampleIds) - if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - if (removed == 0) return; // nothing changed — no persist, no undo point - - persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped + if (!bankOpRemove(sampleIds, srcBankId)) + return; // nothing changed — no persist, no undo point // The selection indexed into the source; after a remove those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; @@ -471,10 +406,189 @@ void showSelectionMenu(int screenX, int screenY) { } // namespace reasampler::panel -// --- Public API (the selection read seam — panel_bank_ops.h) ------------------- +// --- Public API (panel_bank_ops.h) --------------------------------------------- namespace reasampler { +namespace { + +// Persists the book after a bank mutation. Mirrors the CAPTURE path, NOT the +// Design-View path: quiet persist — saveToActiveProject no-ops on an unsaved project +// (the change stays valid for the session and persists on the user's next save). +// Deliberately NO Save-As prompt; do not "align" with persistViewState's prompt +// idiom. Returns whether a persist actually happened, so persistBankOp can discard +// its undo block when nothing was written. Session pointer is live for the whole +// extension lifetime (bankPanelInit at load, before any action registers). +bool persistBook() { return panel::g_panel.session->saveToActiveProject(); } + +// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model +// design: ids are caller-supplied and stable; the model stays pure and mints none). +// Distinct from a track GUID by origin only — both are canonical guidToString output. +std::string mintBankId() { + GUID g{}; + genGuid(&g); + char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) + guidToString(&g, buf); + return std::string(buf); +} + +} // namespace + +// One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp byte-identical twins. +// COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', +// so the return separator is overridden to \x1f (un-typeable) via the documented +// `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. +bool promptText(const char* title, const char* caption, const std::string& initial, + std::string& out) { + std::vector buf(512, '\0'); + // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. + std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); + const std::string captions = std::string(caption) + ",separator=\x1f"; + if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), + static_cast(buf.size()))) + return false; // user cancelled + std::string s(buf.data()); + if (s.empty()) return false; // an empty name is not a valid bank name + out = std::move(s); + return true; +} + +// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — +// one bank op = one Ctrl-Z. +// +// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project +// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures +// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents +// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). +// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family +// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them +// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs +// INSIDE the block so the post-mutation ext-state is the block's "after" image. +// +// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops +// (nothing is written to ext state). We must still CLOSE the block we opened, but with +// an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a +// no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model +// change stands and persists on the user's next save; it just earns no undo point until +// there is a project to persist into (undo of an unsaved bank op has nothing to roll +// back to anyway). The Begin/End must still be balanced, hence the close-either-way. +void persistBankOp(const char* label, bool bumpGeneration) { + Undo_BeginBlock2(nullptr); + // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the + // fresh generation rides the same ext-state write the persist makes (persistBook() -> + // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs + // (the caller decides); a pure-organizational verb passes false and leaves the counter be, + // so a rename/activate does not needlessly refresh live instances. + if (bumpGeneration) panel::g_panel.session->bumpBankGeneration(); + const bool persisted = persistBook(); + if (persisted) + Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); + else + Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point +} + +// --- Promptless inner bank verbs (Q-W4 single home) ---------------------------- +// Model op + persistBankOp only; NO UX. Callers own prompts/confirms/nudges. Each +// verb resolves the book fresh (panel::book(), null when no live session) and +// persists ONLY after the model accepted — a rejected op opens no undo point. + +std::string bankOpCreate(const std::string& name) { + BankBook* b = panel::book(); + if (!b) return {}; + const std::string id = mintBankId(); + if (!b->createBank(id, name)) return {}; // duplicate display name (model rule) + persistBankOp("ReaSampler: create bank"); + return id; +} + +bool bankOpRename(const std::string& bankId, const std::string& newName) { + BankBook* b = panel::book(); + if (!b || !b->renameBank(bankId, newName)) return false; // pool / name in use + persistBankOp("ReaSampler: rename bank"); + return true; +} + +bool bankOpDelete(const std::string& bankId, bool bumpGeneration) { + BankBook* b = panel::book(); + if (!b || !b->deleteBank(bankId)) return false; // pool un-deletable (model rule) + persistBankOp("ReaSampler: delete bank", bumpGeneration); + return true; +} + +bool bankOpEvacuate(const std::string& bankId) { + BankBook* b = panel::book(); + if (!b || !b->evacuate(bankId)) return false; // pool is a destination, not a source + // S9: evacuate moves members between banks (bank membership changes) -> bump. + persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); + return true; +} + +bool bankOpActivate(const std::string& bankId) { + BankBook* b = panel::book(); + if (!b || !b->setActiveBank(bankId)) return false; // rejects an unknown id + persistBankOp("ReaSampler: activate bank"); + return true; +} + +// NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb): +// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes +// unconditionally before the dest add collapses on hash), so the index DID +// mutate — it counts toward opening an undo point. +// * COPY collapse: the source is left intact AND the dest already held the hash, +// so NOTHING changed — a true index no-op. It must NOT open an undo point. +// Hence: copy counts only real gains; move counts gains OR collapses. Ids pass +// straight to the model op — no BankModel& cached across the loop's mutations. +bool bankOpTransfer(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy) { + BankBook* b = panel::book(); + if (!b || sampleIds.empty() || srcBankId == destBankId) return false; + if (!b->bank(srcBankId) || !b->bank(destBankId)) return false; + int ok = 0, collapsed = 0; + for (const std::string& sid : sampleIds) { + const TransferResult r = + copy ? b->copySample(sid, srcBankId, destBankId) + : b->moveSample(sid, srcBankId, destBankId); + switch (r) { + case TransferResult::Moved: + case TransferResult::Copied: ++ok; break; + case TransferResult::Collapsed: ++collapsed; break; + case TransferResult::RejectedUnknownBank: + case TransferResult::RejectedSampleAbsent: + case TransferResult::RejectedSameBank: break; + } + } + const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); + if (!mutated) return false; // nothing changed — no persist, no undo point + // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an + // instance may reference) -> bump so assigned instances refresh hands-free. + persistBankOp(copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", + /*bumpGeneration=*/true); + return true; +} + +// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks +// stays latent in the model). Non-destructive to the file: a last-reference remove +// leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes +// (the manifest is untouched). Silent: recoverability is the batched undo (R-B). +bool bankOpRemove(const std::vector& sampleIds, + const std::string& srcBankId) { + BankBook* b = panel::book(); + if (!b || sampleIds.empty() || !b->bank(srcBankId)) return false; + int removed = 0; + for (const std::string& sid : sampleIds) + if (b->removeSample(sid, srcBankId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + if (removed == 0) return false; // every id already absent — no undo point + // S9: a remove drops a sample from a bank (an instance referencing it must refresh — + // it will resolve to silence, per the stale-id policy) -> bump. + persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); + return true; +} + +// --- Selection read seam -------------------------------------------------------- + std::vector bankPanelSelectedSampleIds() { return panel::focusedSelectionIds(); } diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h index 6853129..afe98b6 100644 --- a/src/shell/panel/panel_bank_ops.h +++ b/src/shell/panel/panel_bank_ops.h @@ -1,19 +1,91 @@ #pragma once // panel_bank_ops — the bank-CRUD + selection-read seam of the bank panel (Q-W2 split -// of bank_panel.h; Phase B4/B5). The .cpp is the single home of the panel-side bank -// verbs (create / rename / delete / evacuate / activate / move / copy / remove), -// each driven against the B1 BankBook model on the session and persisted via -// persistBankOp (one bank op = one Ctrl-Z) — the owner Q-W4 dedupes actions.cpp -// against. This header carries the panel's public selection-read surface. +// of bank_panel.h; Phase B4/B5). The .cpp is the SINGLE implementation home of the +// bank verbs (create / rename / delete / evacuate / activate / move / copy / remove): +// each promptless inner verb below drives the B1 BankBook model on the session and +// persists via persistBankOp (one bank op = one Ctrl-Z). Q-W4 dedupe: the panel's +// menu handlers and the bank_actions bindable family are both thin UX skins +// (prompts / confirms / console vs. message boxes / panel-state nudges) over these +// one-home verbs. This header carries that verb surface, the shared prompt/persist +// helpers, and the panel's public selection-read surface. // -// REAPER-free: main.cpp (insert action) and actions.cpp read the selection through -// these free functions. +// The selection reads are REAPER-free; the verbs and helpers are REAPER-facing +// (persist + stock dialogs) but SDK-free in this header. #include #include namespace reasampler { +// --- Promptless inner bank verbs (Q-W4 single home) -------------------------- +// Each verb: model op on the session's BankBook + persistBankOp (undo-batched +// ext-state persist) — NO prompts, NO message boxes, NO panel-state nudges. The +// caller owns all UX. Every verb returns whether the model accepted the mutation +// (a rejected op persists nothing and opens no undo point). Verbs resolve the +// session via the panel's live session pointer (set at load by bankPanelInit, +// before any action can fire) and fail safe (false / "") when it is absent. + +// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id, +// or "" when the model rejects the name (duplicate, trimmed + case-insensitive). +// Create is purely organizational — no generation bump. +std::string bankOpCreate(const std::string& name); + +// Renames `bankId`. False when the model rejects (pool un-renamable / name in use). +bool bankOpRename(const std::string& bankId, const std::string& newName); + +// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller +// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete +// (an evacuate-then-delete flow must still bump on the ORIGINAL membership). +bool bankOpDelete(const std::string& bankId, bool bumpGeneration); + +// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool +// itself). Bumps the generation (membership changed). +bool bankOpEvacuate(const std::string& bankId); + +// Activates `bankId` as the capture target. False on an unknown id. No bump. +bool bankOpActivate(const std::string& bankId); + +// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to +// `destBankId` (index-only; files never relocate). Returns whether the index +// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes +// nothing (no undo point); a MOVE collapse did remove the source entry (counts). +// Persists ONE undo point ("move/copy sample(s)") only when mutated. +bool bankOpTransfer(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); + +// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes +// bytes). Returns whether anything was removed; persists one undo point when so. +bool bankOpRemove(const std::vector& sampleIds, + const std::string& srcBankId); + +// --- Shared UX/persist helpers ------------------------------------------------ + +// Prompts the user for a single line of text via REAPER's stock input dialog +// (GetUserInputs). `initial` pre-fills the field. Returns false (leaving `out` +// untouched) on cancel or an empty entry. COMMA GUARD: the return separator is +// overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, +// so any printable name — commas included — round-trips whole (SDK ~3806/3808). +// One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp twins. +bool promptText(const char* title, const char* caption, const std::string& initial, + std::string& out); + +// Persists a completed bank-index verb as a single REAPER undo point (R-B). +// Wraps the session persist (SetProjExtState) in a Begin/End block with +// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project +// the persist no-ops and the block is closed with an empty label + zero flag (REAPER +// discards it). Callers must invoke this ONLY after a successful/effective mutation — +// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching +// here so no empty undo point is ever opened for a no-op. +// +// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a +// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave +// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / +// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so +// the stamped counter rides the same ext-state write and undo captures the pre/post +// generation with the rest of the blob. +void persistBankOp(const char* label, bool bumpGeneration = false); + // The stable ids of the currently-selected samples, in bank (insertion) order. // Empty when nothing is selected or the panel has never opened. This is the clean // seam the `insert` action reads to know WHAT to place — it returns ids (not grid diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 68fb38e..f2999b2 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -20,7 +20,7 @@ #include "shell/panel/panel_state.h" -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B) +#include "shell/panel/panel_bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B) #include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) #include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 5163172..84243fb 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -17,7 +17,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_input.h" -#include "actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) +#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) #include "persist.h" // ReaSamplerSession — view/tail reads + mutation #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index 320c644..b1a2e30 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -217,7 +217,7 @@ std::string activeModeIdOrEmpty() { // The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons // then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from -// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; +// design_view_actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; // VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not // paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). // The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the diff --git a/src/shell/panel/panel_layout.h b/src/shell/panel/panel_layout.h index ef81c1a..72ac6ea 100644 --- a/src/shell/panel/panel_layout.h +++ b/src/shell/panel/panel_layout.h @@ -6,7 +6,7 @@ // row/cluster builders, region rects, the L7 slot-order display bridge) is internal to // panel_layout.cpp (see panel_state.h for the intra-panel seam). // -// REAPER-free: main.cpp / actions.cpp drive these through plain free functions. +// REAPER-free: main.cpp / bank_actions.cpp drive these through plain free functions. namespace reasampler { diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index e328222..16fdefc 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -16,10 +16,11 @@ // * Explicit using-declarations pulling the pure modules' symbols into // reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — this // header itself does not directly include the interim core/namespaces.h shim -// (Q-W2 retires that direct dependency for this module). Six of the eight panel -// TUs still pull the shim in TRANSITIVELY via actions.h/persist.h/ingest.h/ -// draw_kit.h/view.h; only panel_thumbnails.cpp and panel_audition.cpp are -// shim-free end to end. Nothing HERE depends on it either way. +// (Q-W2 retires that direct dependency for this module; Q-W4 retired the +// actions.h carrier with the actions split). Several panel TUs still pull the +// shim in TRANSITIVELY via persist.h/ingest.h/draw_kit.h/view.h; only +// panel_thumbnails.cpp and panel_audition.cpp are shim-free end to end. +// Nothing HERE depends on it either way. // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, From 4f587258b224a6d792900686ad89ad85f147e79f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 13:06:12 -0400 Subject: [PATCH 34/40] fix: guard persistBankOp/persistBook against a null session; rename promptText to promptBankName --- src/shell/actions/bank_actions.cpp | 16 ++++++++-------- src/shell/panel/panel_bank_ops.cpp | 25 ++++++++++++++++++------- src/shell/panel/panel_bank_ops.h | 10 ++++++++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp index 67ddf94..0defb26 100644 --- a/src/shell/actions/bank_actions.cpp +++ b/src/shell/actions/bank_actions.cpp @@ -1,7 +1,7 @@ // bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of // actions.cpp). See bank_actions.h. // -// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptText), +// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptBankName), // name resolution, and console feedback — over the promptless bankOp* inner verbs // homed in panel_bank_ops (model op + persistBankOp, one bank op = one Ctrl-Z). The // book's rules (pool privileges, collapse-by-hash, active-fallback-to-pool) all live @@ -28,7 +28,7 @@ #include "core/model/bank_book.h" // BankBook, nextBankId, kPoolBankId (B1) #include "persist.h" // ReaSamplerSession (owns book()) -#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptText + selection seam +#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptBankName + selection seam #include "shell/panel/panel_layout.h" // full-height toggles (B3) #define REAPERAPI_MINIMAL @@ -115,7 +115,7 @@ std::string bankIdByDisplayName(const std::string& name) { // create then fails and the user is told the name is taken. void doBankCreate() { std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; + if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; if (bankOpCreate(name).empty()) { ShowConsoleMsg( ("ReaSampler: could not create bank \"" + name + @@ -129,7 +129,7 @@ void doBankCreate() { // self-contained; the panel renames in place on a tab. void doBankRename() { std::string which; - if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "", + if (!promptBankName("ReaSampler: rename bank", "Bank to rename (current name):", "", which)) return; const std::string id = bankIdByDisplayName(which); @@ -138,7 +138,7 @@ void doBankRename() { return; } std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return; + if (!promptBankName("ReaSampler: rename bank", "New name:", which, newName)) return; if (!bankOpRename(id, newName)) { // The verb rejects the pool (un-renamable) or a name already used by another // bank (unique display names, trimmed + case-insensitive). @@ -154,7 +154,7 @@ void doBankRename() { // panel confirm (naming evacuate inline, with a one-click evacuate) lives in the panel. void doBankDelete() { std::string which; - if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return; + if (!promptBankName("ReaSampler: delete bank", "Bank to delete:", "", which)) return; const std::string id = bankIdByDisplayName(which); if (id.empty()) { ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); @@ -194,7 +194,7 @@ void doBankDelete() { // intended "keep the samples" companion to delete. void doBankEvacuate() { std::string which; - if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", + if (!promptBankName("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", which)) return; const std::string id = bankIdByDisplayName(which); @@ -245,7 +245,7 @@ void doBankTransferSelected(bool copy) { const char* verb = copy ? "copy" : "move"; const std::string title = std::string("ReaSampler: ") + verb + " selected samples"; std::string destName; - if (!promptText(title.c_str(), "Destination bank:", "", destName)) return; + if (!promptBankName(title.c_str(), "Destination bank:", "", destName)) return; const std::string destId = bankIdByDisplayName(destName); if (destId.empty()) { ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index d3d8b3b..497c326 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -95,7 +95,7 @@ std::vector namedBanks() { void doCreateBank() { if (!book()) return; std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; + if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; const std::string id = bankOpCreate(name); if (id.empty()) { ShowMessageBox("A bank with that name already exists.", @@ -115,7 +115,7 @@ void doRenameBank(const std::string& bankId) { if (!bk || bk->isPool()) return; const std::string current = bk->displayName; // copy before any mutation std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; + if (!promptBankName("ReaSampler: rename bank", "New name:", current, newName)) return; if (!bankOpRename(bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); @@ -417,9 +417,13 @@ namespace { // (the change stays valid for the session and persists on the user's next save). // Deliberately NO Save-As prompt; do not "align" with persistViewState's prompt // idiom. Returns whether a persist actually happened, so persistBankOp can discard -// its undo block when nothing was written. Session pointer is live for the whole -// extension lifetime (bankPanelInit at load, before any action registers). -bool persistBook() { return panel::g_panel.session->saveToActiveProject(); } +// its undo block when nothing was written. Guards a null session pointer (false, +// no-op) — see persistBankOp's guard below for why this is defensive rather than +// dead code. +bool persistBook() { + if (!panel::g_panel.session) return false; // no live session: nothing to persist + return panel::g_panel.session->saveToActiveProject(); +} // Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model // design: ids are caller-supplied and stable; the model stays pure and mints none). @@ -438,8 +442,8 @@ std::string mintBankId() { // COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', // so the return separator is overridden to \x1f (un-typeable) via the documented // `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { +bool promptBankName(const char* title, const char* caption, const std::string& initial, + std::string& out) { std::vector buf(512, '\0'); // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); @@ -472,7 +476,14 @@ bool promptText(const char* title, const char* caption, const std::string& initi // change stands and persists on the user's next save; it just earns no undo point until // there is a project to persist into (undo of an unsaved bank op has nothing to roll // back to anyway). The Begin/End must still be balanced, hence the close-either-way. +// +// NULL-SESSION GUARD: this is a public API (panel_bank_ops.h) with callers outside +// this TU (e.g. panel_drag.cpp), not all of which are guaranteed to have re-checked +// the session pointer immediately beforehand. Bail out BEFORE Undo_BeginBlock2 — no +// block is opened, so there is nothing to balance and no risk of an unbalanced +// Begin/End pair. void persistBankOp(const char* label, bool bumpGeneration) { + if (!panel::g_panel.session) return; // no live session: no-op, no undo point opened Undo_BeginBlock2(nullptr); // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the // fresh generation rides the same ext-state write the persist makes (persistBook() -> diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h index afe98b6..2f1c5ac 100644 --- a/src/shell/panel/panel_bank_ops.h +++ b/src/shell/panel/panel_bank_ops.h @@ -67,8 +67,8 @@ bool bankOpRemove(const std::vector& sampleIds, // overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, // so any printable name — commas included — round-trips whole (SDK ~3806/3808). // One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp twins. -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out); +bool promptBankName(const char* title, const char* caption, const std::string& initial, + std::string& out); // Persists a completed bank-index verb as a single REAPER undo point (R-B). // Wraps the session persist (SetProjExtState) in a Begin/End block with @@ -78,6 +78,12 @@ bool promptText(const char* title, const char* caption, const std::string& initi // rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching // here so no empty undo point is ever opened for a no-op. // +// NULL-SESSION GUARD: this is a public API with callers outside panel_bank_ops.cpp +// (e.g. panel_drag.cpp). If the panel's session pointer is absent (no live session), +// this is a no-op — no undo block is opened. Today every real caller only reaches +// here via a prior session-backed check, so the guard is not yet reachable in +// practice; it exists to make the function safe to call standalone. +// // S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a // live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave // it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / From 75aa93f913c18b955cd7f3b42c66877028341f55 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 12:56:06 -0400 Subject: [PATCH 35/40] =?UTF-8?q?Q-W5:=20persist=20=E2=86=92=20session/ext?= =?UTF-8?q?=5Fstate=5Fio/prune=5Ffs=20(deletion=20authority=20concentrated?= =?UTF-8?q?);=20one=20GetProjExtState=20grow-loop=20in=20bridge=5Fmarshal?= =?UTF-8?q?=20(T2-04,=20=C3=973=20rewired);=20bank=5Fbook=20JSON=20codec?= =?UTF-8?q?=20=E2=86=92=20bank=5Fbook=5Fjson=20via=20private=20static=20na?= =?UTF-8?q?meKey;=20persist.h=20stays=20umbrella.=2061/61=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 17 +- src/core/instrument/map/bridge_marshal.h | 60 ++ src/core/model/bank_book.cpp | 303 +------- src/core/model/bank_book.h | 10 + src/core/model/bank_book_json.cpp | 309 ++++++++ src/persist.cpp | 853 ----------------------- src/persist.h | 355 +--------- src/shell/instrument/reaper_bridge.cpp | 28 +- src/shell/persist/ext_state_io.cpp | 403 +++++++++++ src/shell/persist/ext_state_io.h | 59 ++ src/shell/persist/persist_internal.h | 51 ++ src/shell/persist/prune_fs.cpp | 304 ++++++++ src/shell/persist/session.cpp | 222 ++++++ src/shell/persist/session.h | 311 +++++++++ src/shell/persist/usage_scan.cpp | 32 +- tests/test_bridge_marshal.cpp | 96 ++- 16 files changed, 1899 insertions(+), 1514 deletions(-) create mode 100644 src/core/model/bank_book_json.cpp delete mode 100644 src/persist.cpp create mode 100644 src/shell/persist/ext_state_io.cpp create mode 100644 src/shell/persist/ext_state_io.h create mode 100644 src/shell/persist/persist_internal.h create mode 100644 src/shell/persist/prune_fs.cpp create mode 100644 src/shell/persist/session.cpp create mode 100644 src/shell/persist/session.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c491bb3..304e682 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -267,7 +267,9 @@ target_link_libraries(slot_map PRIVATE json) # sample between banks, JSON round-trip + legacy-bank_index→pool migration. # Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched). # --------------------------------------------------------------------------- -add_library(bank_book STATIC src/core/model/bank_book.cpp) +add_library(bank_book STATIC + src/core/model/bank_book.cpp + src/core/model/bank_book_json.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) target_link_libraries(bank_book PUBLIC slot_map) @@ -822,8 +824,10 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests) # 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE. # editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math # (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge -# read marshalling — GetProjExtState result decode + a small JSON string-field -# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; +# read marshalling — GetProjExtState result decode + the ONE grow-loop retry +# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge +# read AND the extension's persist/usage_scan ext-state reads (hence linked into +# reaper_reasampler too). Both are unit-tested outside the DAW; # the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified. # --------------------------------------------------------------------------- add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp) @@ -1100,7 +1104,9 @@ add_library(reaper_reasampler MODULE src/shell/capture/capture_realtime_shell.cpp src/shell/capture/capture_realtime_finalize.cpp src/core/capture/capture_realtime.cpp - src/persist.cpp + src/shell/persist/session.cpp + src/shell/persist/ext_state_io.cpp + src/shell/persist/prune_fs.cpp src/shell/panel/panel_audition.cpp src/shell/panel/panel_bank_ops.cpp src/shell/panel/panel_drag.cpp @@ -1129,6 +1135,7 @@ add_library(reaper_reasampler MODULE src/shell/actions/prune_action.cpp src/ingest.cpp src/core/model/bank_book.cpp + src/core/model/bank_book_json.cpp src/core/model/owned_manifest.cpp src/shell/actions/drag_out_win.cpp src/shell/actions/instrument_drop_win.cpp @@ -1141,7 +1148,7 @@ add_library(reaper_reasampler MODULE src/core/ui/card_drag.cpp src/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest 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) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest 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 bridge_marshal sample_usage) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index 9e918f4..d63db44 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -21,6 +21,8 @@ #include #include +#include +#include namespace reasampler::instrument::map { @@ -34,4 +36,62 @@ namespace reasampler::instrument::map { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); +// --------------------------------------------------------------------------- +// The GetProjExtState GROW-LOOP retry policy (Q-W5 rider, T2-04). +// --------------------------------------------------------------------------- +// GetProjExtState writes into a caller-supplied buffer with no documented +// query-the-size call, so a large value (bank blob, usage record) must be read by +// growing a buffer until the value fits strictly inside it. Three shells carried +// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's +// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE +// policy now lives here so the retry/termination rules cannot drift. The fiddly +// part is the termination taxonomy, which each caller folds differently: +// +// * Absent — the API returned <= 0 on some attempt: the key holds no value. +// (persist -> "" empty bank; usage_scan / bridge -> nullopt) +// * Complete — the written C string fits STRICTLY inside the buffer (size+1 < +// cap), so it cannot have been clipped: `value` is the whole value. +// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable +// WHOLE, which is NOT the same as absent. (persist warns on the +// console; usage_scan folds it to the prune fail-safe abort) +// +// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap), +// returning the API's int. A template, statically dispatched per call site — no +// virtual calls, no std::function (the §3 performance guardrail); the caller binds +// the project/namespace/key (or a resolved function pointer, VST side) in a lambda. +struct GrowingExtStateRead { + enum class Status { Absent, Complete, Overflow }; + Status status = Status::Absent; + int apiReturn = 0; // the FINAL attempt's return (<= 0 iff Absent); feeds + // decodeGetProjExtState on the bridge path unchanged + std::string value; // the whole value; meaningful only when Complete +}; + +template +GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { + // Start generous; grow ×4 if REAPER reports the value may have been clipped + // (the return is the value length; equal-to-capacity-minus-NUL is ambiguous, + // so only a strict fit terminates). Ceiling 16 MB — give up rather than loop + // forever on a pathological value. + GrowingExtStateRead result; + for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = read(buf.data(), cap); + result.apiReturn = rv; + if (rv <= 0) { + result.status = GrowingExtStateRead::Status::Absent; + return result; + } + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) { + result.status = GrowingExtStateRead::Status::Complete; + result.value = std::move(s); + return result; + } + // else: possibly truncated -> grow and retry. + } + result.status = GrowingExtStateRead::Status::Overflow; + return result; +} + } // namespace reasampler::instrument::map diff --git a/src/core/model/bank_book.cpp b/src/core/model/bank_book.cpp index 20372ac..4700ae6 100644 --- a/src/core/model/bank_book.cpp +++ b/src/core/model/bank_book.cpp @@ -3,18 +3,13 @@ #include #include -#include "core/json/json.h" - -// bank_book implementation. -// -// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model -// and view_mode_model. The book blob nests one bank object per bank, each carrying that -// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), -// so per-bank sample serialization stays owned by bank_model and is not duplicated -// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a -// raw "index" member whose value is the BankModel blob verbatim; the parser splits -// the book envelope, then hands each nested index blob straight to -// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped. +// bank_book implementation — the registry RULES half: construction, pool +// privileges, bank lifecycle, active bank, sample movement/removal, slot order, +// and the reference queries. The JSON round-trip half (serialize / deserialize — +// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled +// into the same bank_book target (the slot_map extraction shape: same header, a +// second TU). The one symbol both halves share is the private static +// BankBook::nameKey display-name folding rule (declared in bank_book.h). namespace reasampler { @@ -84,14 +79,13 @@ void BankBook::normalizeOrdinals() { // Display-name uniqueness (trimmed + case-insensitive, ASCII) // --------------------------------------------------------------------------- -namespace { - // Folds a display name to its uniqueness key: strip leading/trailing ASCII // whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one // key and cannot coexist. ASCII-only by design — the pure core carries no locale // facility and must not grow one; bank names are short user labels, not full Unicode -// case-folding candidates. -std::string nameKey(const std::string& s) { +// case-folding candidates. Private static member (Q-W5): the one folding rule shared +// with bank_book_json.cpp's parse-time duplicate-display-name coalesce. +std::string BankBook::nameKey(const std::string& s) { std::size_t b = 0, e = s.size(); auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; while (b < e && isWs(s[b])) ++b; @@ -106,8 +100,6 @@ std::string nameKey(const std::string& s) { return out; } -} // namespace - // True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The // exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name. bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const { @@ -425,279 +417,12 @@ std::vector BankBook::referencedPaths() const { } // =========================================================================== -// JSON — writer +// JSON — serialize / deserialize / adoptBanks live in bank_book_json.cpp +// (Q-W5 extraction; byte-identical format, golden-literal-pinned by the Q-W1 +// test). loadFromPersisted stays here: it is the load-source PRECEDENCE rule +// (banks-blob vs legacy vs empty), not the codec. // =========================================================================== -namespace { - -// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the -// prior file-local writer carried, so the emitted blob is byte-identical. -std::string intToStr(int v) { return json::numToStr(v); } -using ObjWriter = json::Writer; - -} // namespace - -std::string BankBook::serialize() const { - std::string out; - { - ObjWriter root(out); - root.keyRaw("version", intToStr(1)); - root.keyStr("activeBank", activeBankId_); - - // banks: array of { id, displayName, ordinal, index: }. - // The pool rides in as bank-zero, persisted identically to any named bank. - root.keyBegin("banks"); - out += '['; - for (std::size_t i = 0; i < banks_.size(); ++i) { - if (i) out += ','; - ObjWriter b(out); - b.keyStr("id", banks_[i].id); - b.keyStr("displayName", banks_[i].displayName); - b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); - // The nested index is bank_model's own JSON, emitted verbatim so the - // per-sample shape stays owned by BankModel::serialize (not duplicated). - b.keyRaw("index", banks_[i].index.serialize()); - // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the - // parser defaults such a bank's slots from insertion order on load. - b.keyRaw("slots", banks_[i].slots.serialize()); - } - out += ']'; - } // root closes here (see bank_model note on NRVO + deferred close) - return out; -} - -// =========================================================================== -// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) -// =========================================================================== - -namespace { - -// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). -// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, -// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive -// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root -// blob, distinguishing the legacy shape (a bare bank_index object: has -// "samples", no "banks") from the book shape (has "banks"): a legacy blob -// yields a single pool bank carrying the migrated index and an empty active id -// (⇒ pool). The member deserialize() adopts the result (ordinal normalize + -// active resolve). -bool parseSlots(json::Reader& r, std::vector>& out); - -bool parseBank(json::Reader& r, Bank& b) { - if (!r.consume('{')) return false; - r.skipWs(); - if (r.consume('}')) return false; // a bank object must at least carry an id - - bool haveId = false; - bool haveIndex = false; - do { - std::string key; - if (!r.parseKey(key)) return false; - - if (key == "id") { - if (!r.parseString(b.id)) return false; - haveId = true; - } else if (key == "displayName") { - if (!r.parseString(b.displayName)) return false; - } else if (key == "ordinal") { - if (!r.parseInt(b.ordinal)) return false; - } else if (key == "index") { - std::string raw; - if (!r.captureValue(raw)) return false; - auto idx = BankModel::deserialize(raw); - if (!idx) return false; // a malformed nested index fails the whole parse - b.index = std::move(*idx); - haveIndex = true; - } else if (key == "slots") { - // L7 display positions. Absent on a pre-L7 blob (the else-branch skips - // nothing because the key never appears); when present it drives the - // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. - std::vector> pairs; - if (!parseSlots(r, pairs)) return false; - b.slots = SlotMap::fromEntries(pairs); - } else { - if (!r.skipValue()) return false; // forward-compat unknown keys - } - } while (r.consume(',')); - - if (!r.consume('}')) return false; - if (!haveId || b.id.empty()) return false; // id keys the registry - if (!haveIndex) return false; // every bank persists its index - return true; -} - -bool parseSlots(json::Reader& r, std::vector>& out) { - out.clear(); - if (!r.consume('[')) return false; - r.skipWs(); - if (r.consume(']')) return true; // empty slot array — a bank with no positions yet - do { - if (!r.consume('{')) return false; - std::string id; - int slot = 0; - bool haveId = false, haveSlot = false; - do { - std::string k; - if (!r.parseKey(k)) return false; - if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } - else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } - else { if (!r.skipValue()) return false; } // forward-compat - } while (r.consume(',')); - if (!r.consume('}')) return false; - if (!haveId || !haveSlot) return false; // a slot entry needs both - out.emplace_back(std::move(id), slot); - } while (r.consume(',')); - return r.consume(']'); -} - -bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks, - std::string& activeBank) { - banks.clear(); - activeBank.clear(); - if (!r.consume('{')) return false; - r.skipWs(); - if (r.consume('}')) return false; // an empty object is neither shape → malformed - - // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a - // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). - std::vector parsedBanks; - bool sawBanks = false; - bool sawSamples = false; - - do { - std::string key; - if (!r.parseKey(key)) return false; - - if (key == "banks") { - sawBanks = true; - if (!r.consume('[')) return false; - r.skipWs(); - if (!r.consume(']')) { - do { - Bank b; - if (!parseBank(r, b)) return false; - parsedBanks.push_back(std::move(b)); - } while (r.consume(',')); - if (!r.consume(']')) return false; - } - } else if (key == "activeBank") { - if (!r.parseString(activeBank)) return false; - } else if (key == "samples") { - // Legacy marker. The legacy index is re-parsed from the whole input below - // (BankModel::deserialize owns that shape); here we only skip the value to - // keep the scan well-formed and note that we saw it. - sawSamples = true; - if (!r.skipValue()) return false; - } else { - if (!r.skipValue()) return false; // version, or unknown - } - } while (r.consume(',')); - - if (!r.consume('}')) return false; - r.skipWs(); - if (!r.eof()) return false; // trailing garbage - - // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- - if (!sawBanks) { - if (!sawSamples) return false; // neither shape's marker → malformed - auto legacy = BankModel::deserialize(raw); - if (!legacy) return false; - Bank pool; - pool.id = kPoolBankId; - pool.displayName = kPoolBankName; - pool.ordinal = 0; - pool.index = std::move(*legacy); - banks.push_back(std::move(pool)); // { pool } with zero named banks - activeBank.clear(); // ⇒ pool (default) after adoption - return true; - } - - // --- Book shape: the parsed banks ARE the book (pool folded in). --- - // The pool must be present as bank-zero (serialize always emits it). Reject a - // book blob that omits it rather than silently re-seeding — a book without its - // pool is malformed, not a legacy blob. - bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(), - [](const Bank& b) { return b.isPool(); }); - if (!hasPool) return false; - - // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup). - for (std::size_t i = 0; i < parsedBanks.size(); ++i) - for (std::size_t j = i + 1; j < parsedBanks.size(); ++j) - if (parsedBanks[i].id == parsedBanks[j].id) return false; - - // Force the pool's fixed display name — it is not user-mutable, so we do not - // trust a persisted override for it (keeps kPoolBankName authoritative). - for (auto& b : parsedBanks) - if (b.isPool()) b.displayName = kPoolBankName; - - // --- Coalesce duplicate folded display names (B4 re-review fold-in). -------- - // The in-model create/rename path enforces unique display names under nameKey, - // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the - // same key ("Drums" and " drums "). Rejecting the whole book over one collision - // would degrade the user's entire library to empty, so instead we AUTO- - // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and - // the first time a folded key repeats, suffix that bank's display name (" 2", - // " 3", …) until its folded key is unique among all names seen so far. The FIRST - // bank to carry a key keeps its name verbatim; only subsequent collisions are - // renamed. No bank or sample is lost, and ids are untouched. The pool is included - // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool" - // is disambiguated away from it, never the reverse. - { - std::vector seenKeys; - seenKeys.reserve(parsedBanks.size()); - for (auto& b : parsedBanks) { - if (b.isPool()) { // pool's name is fixed; reserve its key - seenKeys.push_back(nameKey(b.displayName)); - continue; - } - const auto taken = [&](const std::string& k) { - return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end(); - }; - std::string key = nameKey(b.displayName); - if (taken(key)) { - // Suffix with an ascending integer until the folded key is free. Guard - // against a pathological blob whose base name already ends in a number - // by folding the candidate each attempt (nameKey normalizes it). - const std::string base = b.displayName; - for (int n = 2;; ++n) { - const std::string candidate = base + " " + std::to_string(n); - const std::string candKey = nameKey(candidate); - if (!taken(candKey)) { - b.displayName = candidate; - key = candKey; - break; - } - } - } - seenKeys.push_back(key); - } - } - - banks = std::move(parsedBanks); - return true; -} - -} // namespace - -void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBank) { - banks_ = std::move(banks); - normalizeOrdinals(); - // Resolve the active bank defensively: fall back to the pool if the persisted id - // names no bank, so a corrupt active id never leaves a dangling capture target. - activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); -} - -std::optional BankBook::deserialize(const std::string& blob) { - std::vector banks; - std::string activeBank; - json::Reader r(blob); - if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; - - BankBook book; - book.adoptBanks(std::move(banks), activeBank); - return book; -} - // --------------------------------------------------------------------------- // Active-bank cycle ordering (pure, free function — mirror of nextModeId) // --------------------------------------------------------------------------- diff --git a/src/core/model/bank_book.h b/src/core/model/bank_book.h index 6a66fe6..333d39b 100644 --- a/src/core/model/bank_book.h +++ b/src/core/model/bank_book.h @@ -342,6 +342,16 @@ private: std::vector banks_; // ordinal order; banks_[0] is always the pool std::string activeBankId_; // always names a live bank; defaults to pool + // Folds a display name to its uniqueness key: strip leading/trailing ASCII + // whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share + // one key and cannot coexist. ASCII-only by design — the pure core carries no + // locale facility and must not grow one. A private STATIC member (Q-W5, settled) + // because BOTH halves of the split implementation need the ONE folding rule: the + // rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp, + // deserialize's duplicate-display-name coalesce) — a drifted second copy would let + // a parsed book violate the create/rename uniqueness invariant. + static std::string nameKey(const std::string& s); + // True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key // (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check; // pass exceptId=id to let a bank keep (or re-case/-space) its own name. diff --git a/src/core/model/bank_book_json.cpp b/src/core/model/bank_book_json.cpp new file mode 100644 index 0000000..d3b2289 --- /dev/null +++ b/src/core/model/bank_book_json.cpp @@ -0,0 +1,309 @@ +#include "core/model/bank_book.h" + +#include +#include +#include + +#include "core/json/json.h" + +// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header, +// compiled into the same bank_book target; the slot_map second-TU shape). The +// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private +// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time +// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename +// uniqueness check does, or a parsed book could violate the in-model invariant. +// +// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model +// and view_mode_model. The book blob nests one bank object per bank, each carrying that +// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), +// so per-bank sample serialization stays owned by bank_model and is not duplicated +// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a +// raw "index" member whose value is the BankModel blob verbatim; the parser splits +// the book envelope, then hands each nested index blob straight to +// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped. +// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it. + +namespace reasampler { + +// =========================================================================== +// JSON — writer +// =========================================================================== + +namespace { + +// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the +// prior file-local writer carried, so the emitted blob is byte-identical. +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; + +} // namespace + +std::string BankBook::serialize() const { + std::string out; + { + ObjWriter root(out); + root.keyRaw("version", intToStr(1)); + root.keyStr("activeBank", activeBankId_); + + // banks: array of { id, displayName, ordinal, index: }. + // The pool rides in as bank-zero, persisted identically to any named bank. + root.keyBegin("banks"); + out += '['; + for (std::size_t i = 0; i < banks_.size(); ++i) { + if (i) out += ','; + ObjWriter b(out); + b.keyStr("id", banks_[i].id); + b.keyStr("displayName", banks_[i].displayName); + b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); + // The nested index is bank_model's own JSON, emitted verbatim so the + // per-sample shape stays owned by BankModel::serialize (not duplicated). + b.keyRaw("index", banks_[i].index.serialize()); + // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the + // parser defaults such a bank's slots from insertion order on load. + b.keyRaw("slots", banks_[i].slots.serialize()); + } + out += ']'; + } // root closes here (see bank_model note on NRVO + deferred close) + return out; +} + +// =========================================================================== +// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) +// =========================================================================== + +namespace { + +// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). +// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, +// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive +// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root +// blob, distinguishing the legacy shape (a bare bank_index object: has +// "samples", no "banks") from the book shape (has "banks"): a legacy blob +// yields a single pool bank carrying the migrated index and an empty active id +// (⇒ pool). The member deserialize() adopts the result (duplicate-display-name +// coalesce + ordinal normalize + active resolve — the coalesce lives THERE, not +// here, because it folds through the private BankBook::nameKey these free +// functions cannot reach). +bool parseSlots(json::Reader& r, std::vector>& out); + +bool parseBank(json::Reader& r, Bank& b) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // a bank object must at least carry an id + + bool haveId = false; + bool haveIndex = false; + do { + std::string key; + if (!r.parseKey(key)) return false; + + if (key == "id") { + if (!r.parseString(b.id)) return false; + haveId = true; + } else if (key == "displayName") { + if (!r.parseString(b.displayName)) return false; + } else if (key == "ordinal") { + if (!r.parseInt(b.ordinal)) return false; + } else if (key == "index") { + std::string raw; + if (!r.captureValue(raw)) return false; + auto idx = BankModel::deserialize(raw); + if (!idx) return false; // a malformed nested index fails the whole parse + b.index = std::move(*idx); + haveIndex = true; + } else if (key == "slots") { + // L7 display positions. Absent on a pre-L7 blob (the else-branch skips + // nothing because the key never appears); when present it drives the + // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. + std::vector> pairs; + if (!parseSlots(r, pairs)) return false; + b.slots = SlotMap::fromEntries(pairs); + } else { + if (!r.skipValue()) return false; // forward-compat unknown keys + } + } while (r.consume(',')); + + if (!r.consume('}')) return false; + if (!haveId || b.id.empty()) return false; // id keys the registry + if (!haveIndex) return false; // every bank persists its index + return true; +} + +bool parseSlots(json::Reader& r, std::vector>& out) { + out.clear(); + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty slot array — a bank with no positions yet + do { + if (!r.consume('{')) return false; + std::string id; + int slot = 0; + bool haveId = false, haveSlot = false; + do { + std::string k; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } + else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } + else { if (!r.skipValue()) return false; } // forward-compat + } while (r.consume(',')); + if (!r.consume('}')) return false; + if (!haveId || !haveSlot) return false; // a slot entry needs both + out.emplace_back(std::move(id), slot); + } while (r.consume(',')); + return r.consume(']'); +} + +bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks, + std::string& activeBank) { + banks.clear(); + activeBank.clear(); + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // an empty object is neither shape → malformed + + // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a + // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). + std::vector parsedBanks; + bool sawBanks = false; + bool sawSamples = false; + + do { + std::string key; + if (!r.parseKey(key)) return false; + + if (key == "banks") { + sawBanks = true; + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { + do { + Bank b; + if (!parseBank(r, b)) return false; + parsedBanks.push_back(std::move(b)); + } while (r.consume(',')); + if (!r.consume(']')) return false; + } + } else if (key == "activeBank") { + if (!r.parseString(activeBank)) return false; + } else if (key == "samples") { + // Legacy marker. The legacy index is re-parsed from the whole input below + // (BankModel::deserialize owns that shape); here we only skip the value to + // keep the scan well-formed and note that we saw it. + sawSamples = true; + if (!r.skipValue()) return false; + } else { + if (!r.skipValue()) return false; // version, or unknown + } + } while (r.consume(',')); + + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage + + // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- + if (!sawBanks) { + if (!sawSamples) return false; // neither shape's marker → malformed + auto legacy = BankModel::deserialize(raw); + if (!legacy) return false; + Bank pool; + pool.id = kPoolBankId; + pool.displayName = kPoolBankName; + pool.ordinal = 0; + pool.index = std::move(*legacy); + banks.push_back(std::move(pool)); // { pool } with zero named banks + activeBank.clear(); // ⇒ pool (default) after adoption + return true; + } + + // --- Book shape: the parsed banks ARE the book (pool folded in). --- + // The pool must be present as bank-zero (serialize always emits it). Reject a + // book blob that omits it rather than silently re-seeding — a book without its + // pool is malformed, not a legacy blob. + bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(), + [](const Bank& b) { return b.isPool(); }); + if (!hasPool) return false; + + // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup). + for (std::size_t i = 0; i < parsedBanks.size(); ++i) + for (std::size_t j = i + 1; j < parsedBanks.size(); ++j) + if (parsedBanks[i].id == parsedBanks[j].id) return false; + + // Force the pool's fixed display name — it is not user-mutable, so we do not + // trust a persisted override for it (keeps kPoolBankName authoritative). + for (auto& b : parsedBanks) + if (b.isPool()) b.displayName = kPoolBankName; + + banks = std::move(parsedBanks); + return true; +} + +} // namespace + +void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBank) { + banks_ = std::move(banks); + normalizeOrdinals(); + // Resolve the active bank defensively: fall back to the pool if the persisted id + // names no bank, so a corrupt active id never leaves a dangling capture target. + activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); +} + +std::optional BankBook::deserialize(const std::string& blob) { + std::vector banks; + std::string activeBank; + json::Reader r(blob); + if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; + + // --- Coalesce duplicate folded display names (B4 re-review fold-in). -------- + // The in-model create/rename path enforces unique display names under nameKey, + // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the + // same key ("Drums" and " drums "). Rejecting the whole book over one collision + // would degrade the user's entire library to empty, so instead we AUTO- + // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and + // the first time a folded key repeats, suffix that bank's display name (" 2", + // " 3", …) until its folded key is unique among all names seen so far. The FIRST + // bank to carry a key keeps its name verbatim; only subsequent collisions are + // renamed. No bank or sample is lost, and ids are untouched. The pool is included + // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool" + // is disambiguated away from it, never the reverse. + // + // Hosted HERE (a static member, Q-W5) rather than in the free parseBook because + // it folds through the PRIVATE BankBook::nameKey — the same rule the + // create/rename uniqueness check applies. Runs after parseBook on BOTH shapes; + // the legacy path yields { pool } alone, where the scan is a trivial no-op. + { + std::vector seenKeys; + seenKeys.reserve(banks.size()); + for (auto& b : banks) { + if (b.isPool()) { // pool's name is fixed; reserve its key + seenKeys.push_back(nameKey(b.displayName)); + continue; + } + const auto taken = [&](const std::string& k) { + return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end(); + }; + std::string key = nameKey(b.displayName); + if (taken(key)) { + // Suffix with an ascending integer until the folded key is free. Guard + // against a pathological blob whose base name already ends in a number + // by folding the candidate each attempt (nameKey normalizes it). + const std::string base = b.displayName; + for (int n = 2;; ++n) { + const std::string candidate = base + " " + std::to_string(n); + const std::string candKey = nameKey(candidate); + if (!taken(candKey)) { + b.displayName = candidate; + key = candKey; + break; + } + } + } + seenKeys.push_back(key); + } + } + + BankBook book; + book.adoptBanks(std::move(banks), activeBank); + return book; +} + +} // namespace reasampler diff --git a/src/persist.cpp b/src/persist.cpp deleted file mode 100644 index dfb3e8f..0000000 --- a/src/persist.cpp +++ /dev/null @@ -1,853 +0,0 @@ -#include "core/namespaces.h" -// persist.cpp — REAPER-facing implementation of the BankModel <-> project -// ext-state bridge (M4). See persist.h for the contract. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API -// pointers; here they are extern (CLAUDE.md §contract). -// -// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the -// whole BankBook (pool as bank-zero + named banks) is written under key "banks" -// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on -// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre- -// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so -// the banks travel with the project automatically (CONTEXT.md §Persistence & paths). -// The only thing that does NOT travel for free is the physical bank folder; on -// Save-As to a new directory we relocate it so the indices' relative paths still -// resolve. -// -// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): -// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active -// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext -// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the -// secondary disambiguator (classifyProjectTransition owns the exact order): -// * different stored GUID -> a different project of record -> LOAD its index; -// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's -// address, so a reopened/new project can present the previous pointer with a -// different GUID), new/unsaved<->saved, and switching between distinct saved -// projects. -// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via -// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings -// diverge going forward. -// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new -// location -> relocate the bank folder from the old dir to the new one, then -// re-GUID. -// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) -// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a -// fork and its parent share a GUID on disk; switching between them read as a -// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer -// RECYCLING — a reopened/new project reusing the previous project's address read -// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first -// catches recycling; the pointer then separates a fork (same GUID, different -// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). -// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` -// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free -// and testable; poll() executes the verdict. -// -// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no -// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not -// a cross-open identity), so we MINT one with genGuid/guidToString and store it -// under kProjExtGuidKey. On Save-As REAPER copies the whole .rpp incl. our ext -// state, so the new project initially shares the old GUID; poll() re-GUIDs it -// (after relocating, or on the forked-sibling Load branch) so identities diverge. -// -// Rationale for the timer: the brief mandates ext-state storage (rules out the -// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with -// ext-state while covering identity-transition load + Save-As detection in one -// place. -// -// DIVISION OF LABOUR (R-B undo): -// * Identity-transition poll (this file, classifyProjectTransition) owns -// open / tab-switch / new / forked-sibling / Save-As-relocation — every case -// where the project OF RECORD changes. -// * The `projectconfig` hook (main.cpp registers project_config_extension_t; -// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project -// identity is unchanged but its ext state rolled back/forward on disk. The -// identity poll sees NoOp there and would never re-read ext state, so the hook -// requests a reload (requestReload) that poll() drains on the next tick, once -// REAPER has restored the block. See requestReload / the poll drain. -// The hook fires on undo AND redo (isUndo true for both), and on normal open -// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open -// flows solely through the identity-transition Load path and never double-loads. -// -// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY -// our own reasampler_bank/ folder. It never touches the user's media, items, or -// other ext-state namespaces. - -#include "persist.h" - -#include -#include -#include -#include -#include -#include - -// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is -// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK -// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, -// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL -// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the -// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing. -#ifdef _WIN32 -#include -#include -#endif - -#include "core/version/app_version.h" -#include "core/capture/capture_paths.h" -#include "core/reclaim/prune_reconcile.h" -#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) -#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_GetProjExtState -#define REAPERAPI_WANT_MarkProjectDirty -#define REAPERAPI_WANT_SetProjExtState -#define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -namespace fs = std::filesystem; - -// Read the active project pointer and its .rpp path in one shot. idx=-1 is the -// current project tab (SDK header line ~1262). The out-buffer receives the full -// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — -// same fact capture.cpp relies on). Returns nullptr proj only when there is no -// active project at all. -void* readActiveProject(std::string& rppPathOut) { - std::vector buf(4096, '\0'); - ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(buf.size())); - rppPathOut.assign(buf.data()); - return proj; -} - -// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> -// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the -// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp -// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths -// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both -// artifacts share one implementation rather than duplicating the parent-of-.rpp step. -std::string projectDirOf(const std::string& rppPath) { - return projectDirOfRpp(rppPath); -} - -// GetProjExtState needs a caller-supplied buffer; the index JSON can be large -// (many samples). Query the required size first (a NULL/zero call is not part of -// the documented contract, so we grow a buffer until it fits). Returns "" when -// the key is absent (GetProjExtState returns <=0) — an absent key is a valid -// empty bank, not an error. -std::string getProjExtStateString(ReaProject* proj, const char* ns, - const char* key) { - // Start generous; grow if REAPER reports the value was truncated. The return - // value is the length of the value (SDK: "returns length"); if it equals the - // buffer capacity minus the NUL, the value may have been clipped, so retry. - for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - int rv = GetProjExtState(proj, ns, key, buf.data(), cap); - if (rv <= 0) return {}; // absent / empty -> empty bank - // If the written string fits strictly inside the buffer it is complete. - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) return s; - // else: possibly truncated -> grow and retry. - } - // Pathologically large (>16 MB) — give up rather than loop forever. Warn on - // the console so this reads as "too large to load", not silent data loss - // (mirrors the malformed-JSON warning in loadFromProject). - ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) + - "' exceeds the 16 MB read ceiling -- ignoring (bank not " - "loaded).\n").c_str()); - return {}; -} - -// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. -// guidToString wants a >=64-char destination (SDK header line ~3846). -std::string genProjectGuidString() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; - guidToString(&g, buf); - return std::string(buf); -} - -// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not -// move — see the handoff for the copy-vs-move rationale). Overwrites existing -// files at the destination so a re-save is idempotent. Best-effort: filesystem -// errors are swallowed and reported to the console rather than thrown across the -// REAPER boundary. Returns true if the copy ran (source existed). -bool relocateBankFolder(const std::string& oldBankDir, - const std::string& newBankDir) { - std::error_code ec; - if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) { - return false; // nothing at the old location to relocate - } - if (oldBankDir == newBankDir) return false; // defensive; plan guards this too - - fs::create_directories(newBankDir, ec); - fs::copy(oldBankDir, newBankDir, - fs::copy_options::recursive | fs::copy_options::overwrite_existing, - ec); - if (ec) { - ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir + - "' failed: " + ec.message() + "\n").c_str()); - return false; - } - return true; -} - -} // namespace - -bool ReaSamplerSession::saveToActiveProject() { - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj) return false; // no active project — nothing to persist - if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - - // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and - // rides in the `banks` key. - const std::string banksJson = book_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtBanksKey, banksJson.c_str()); - - // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty - // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This - // realizes retirement concretely — after any save, a formerly-legacy project - // carries `banks` and NO `bank_index`, and going forward the legacy key is never - // written. Cheap and idempotent when the key is already absent. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtIndexKey, ""); - - // Additive: the Design-View model rides alongside the banks in its own key. - // Independent write — does not disturb the `banks` blob above. - const std::string viewJson = view_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtViewKey, viewJson.c_str()); - - // Additive: the docked panel's tail setting rides alongside in its own key, so the - // tail choice travels inside the .rpp. Independent write — does not disturb the - // bank_index or view_state above. - const std::string tailJson = serializeTailSetting(tail_); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtTailKey, tailJson.c_str()); - - // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own - // `owned_files` key. Independent write — does not disturb the blobs above. Written - // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, - // and so the manifest and the bank stay in lockstep on disk (both persisted by the - // same saveToActiveProject the capture add-path calls). Uses the channel-derived - // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. - const std::string ownedJson = owned_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtOwnedKey, ownedJson.c_str()); - - // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under - // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty - // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is - // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as - // Stamped on read-back and stays byte-identical to stable regardless of channel; the - // channel is already carried by the isolated namespace (projExtNamespace) this writes to. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtVersionKey, stampVersion().c_str()); - - // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME - // seam so the counter and MarkProjectDirty stay paired. The value is whatever - // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so - // every content mutation's own save carries the fresh generation the instrument reads. The - // format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree - // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtBankGenKey, - instrument::map::formatBankGeneration(bankGeneration_).c_str()); - - MarkProjectDirty(static_cast(proj)); - return true; -} - -bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj) return false; // no active project — nothing to signal - if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - - // One-shot write of the ingest assignment request under its own key (S8). Independent - // of the book/view/tail blobs — this is a transient signal to the instrument, not - // session state that must ride every save. Uses the channel-derived namespace - // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta - // instrument reads only a beta extension's assignment requests. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtAssignKey, wire.c_str()); - MarkProjectDirty(static_cast(proj)); - return true; -} - -namespace { - -// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always -// exact (tallied over the full orphan set), but the enumerated file list handed to the -// console is clipped to this many entries so a project with thousands of orphans does -// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can -// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling. -constexpr std::size_t kPruneListDisplayCap = 64; - -// A fresh enumerate + pure-core prune compute for the active project. Shared by the -// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion -// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path -// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the -// active project, enumerates the folder) but writes nothing. -// -// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty -// when there is no active/saved project, no project dir, or no folder on -// disk yet -> the caller treats an empty dir as "nothing to reclaim". -// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration -// order, untruncated. The pure core decides; this only supplies inputs. -// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd). -// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could -// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY — -// the prune must halt rather than proceed with degraded protection. -// An empty orphan set is itself the delete-side guarantee (every -// consumer of this scan deletes at most `orphans ∩ ...`), the flag is -// what lets the action TELL the user instead of claiming "no orphans". -struct PruneScan { - std::string bankDirAbs; - std::vector orphans; - std::unordered_map sizeByRel; - bool abortedUnreadableUsage = false; - std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage -}; - -// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem -// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI. -PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) { - PruneScan scan; - - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan - - // Resolve the CURRENT bank folder the same way the index does (M4): project dir of - // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a - // Save-As relocation is followed automatically. resolveBankFile is the shared M4 - // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder. - const std::string projectDir = projectDirOf(rppPath); - const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder); - if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan - - std::error_code ec; - if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) { - return scan; // no bank folder captured yet -> nothing to reclaim - } - - // Enumerate the folder into project-relative index-spelled paths, spelled the SAME - // way the capture path spelled them (bankRelativeForName == deriveBankPaths's - // convention) so the pure core's exact-string match lines up with referencedPaths() - // and the manifest. Non-recursive: the bank folder is flat (capture writes files - // directly here); skip any subdirectory. Size is stat'd here and cached by relative - // path so the report's byte tally reuses the same on-disk read. - // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration - // failure (file removed, permission flip) breaks out with a best-effort partial list - // rather than propagating std::filesystem_error across REAPER's C ABI. - std::vector present; - fs::directory_iterator it(bankDir, ec); - for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { - const auto& entry = *it; - std::error_code reg_ec; - if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials - const std::string name = entry.path().filename().string(); - const std::string rel = bankRelativeForName(name); - if (rel.empty()) continue; - present.push_back(rel); - std::error_code sz_ec; - const std::uintmax_t sz = entry.file_size(sz_ec); - scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); - } - - // The decision lives in the pure core — read-only inputs from the book and manifest. - // referencedPaths() unions across the whole book (pool included); owned().paths() is - // the manifest set. pS-usage: the referenced set additionally unions every LIVE - // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance - // rsusage_* records + the live FX enumeration; sample_usage decides liveness, - // including the protect-all net when zero instances were identified) — a capture - // any live instance holds can NEVER be an orphan, even when its bank entry was - // deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY, - // preserving this scan's no-write contract. This shell only enumerates, resolves, - // and stats. - scan.bankDirAbs = bankDir; - const UsageScanResult usage = liveInstanceHeldPaths(proj); - if (usage.abortPrune) { - // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the - // protected set is unknowable. Compute NO orphans — every downstream consumer - // (dry-run report, confirm set, fresh-recompute delete plan) then deletes - // nothing. The flag + key names surface the reason so the action can name each - // offending key for operator recovery. - scan.abortedUnreadableUsage = true; - scan.offendingUsageKeys = usage.offendingKeys; - return scan; - } - scan.orphans = pruneOrphans( - present, mergeReferenced(book.referencedPaths(), usage.heldPaths), - owned.paths()); - return scan; -} - -} // namespace - -PruneReport ReaSamplerSession::pruneDryRun() const { - const PruneScan scan = scanPruneOrphans(book_, owned_); - // buildPruneReport tallies count / byte-sum / display-truncation — no report logic - // re-implemented here. An empty scan (no project / no folder) yields a zero report. - PruneReport report = - buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); - // pS-usage fail-safe: surface the unreadable-record abort so the action halts with - // an explicit message instead of reporting "no orphaned files" (the count IS zero — - // the scan computed nothing — but the user must know the prune refused to run). - // The offending key names propagate so the action can name each one for recovery. - report.abortedUnreadableUsage = scan.abortedUnreadableUsage; - report.offendingUsageKeys = scan.offendingUsageKeys; - return report; -} - -std::vector ReaSamplerSession::pruneOrphanSet() const { - return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated -} - -namespace { - -// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the -// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases: -// * `outAlreadyAbsent` set true — the file was already gone before we touched it; -// the caller folds this into the stale/staleness tally, NOT reclaimedCount. -// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error); -// the caller folds this into skippedCount. -// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception -// may cross the C ABI. -// -// Per-platform routing: -// * Windows — SHFileOperationW(FO_DELETE, pFrom=, FOF_ALLOWUNDO | -// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the -// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our -// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true. -// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this -// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3 -// confirm guardrail. `outUsedTrash` left as-is (false). -bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, - bool& outAlreadyAbsent) { -#ifdef _WIN32 - // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string. - // SHFileOperation's pFrom is a list; a single path still needs the extra terminating - // NUL. Backslashes are required (shell APIs reject forward slashes in some cases). - std::string win = absPath; - for (char& c : win) if (c == '/') c = '\\'; - - const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); - if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false) - std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL - MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); - // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen] - // makes it the double-NUL-terminated single-element list SHFileOperation wants. - - SHFILEOPSTRUCTW op{}; - op.hwnd = nullptr; - op.wFunc = FO_DELETE; - op.pFrom = wbuf.data(); - op.pTo = nullptr; - op.fFlags = static_cast(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | - FOF_SILENT | FOF_NOERRORUI); - const int rv = SHFileOperationW(&op); - if (rv == 0 && !op.fAnyOperationsAborted) { - outUsedTrash = true; - return true; // deleted this call -> reclaimed - } - // SHFileOperation failed (e.g. file already gone yields a nonzero code on some - // versions, or a lock). Distinguish "already absent" from a real failure so the - // caller can tally them separately (absent -> staleness skip; failure -> locked skip). - std::error_code ec; - if (!fs::exists(absPath, ec)) { - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim - } - return false; -#else - // No portable trash surface on SWELL platforms -> hard unlink behind the confirm. - std::error_code ec; - const bool removed = fs::remove(absPath, ec); - if (removed) return true; // deleted this call -> reclaimed - if (ec) return false; // a real failure (locked / permission) -> skip - // remove returned false with no error == the file did not exist -> already gone. - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim - return false; -#endif -} - -} // namespace - -PruneDeletionResult ReaSamplerSession::pruneReclaim( - const std::vector& confirmed) const { - PruneDeletionResult result; - - // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets - // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became - // referenced between confirm and delete drops out of freshOrphans and is skipped; a - // newly-appeared orphan not in `confirmed` is never swept without its own confirm. - // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced - // and NO hand-dropped file — the R-C/R-D safety survives the recompute. - // pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an - // EMPTY orphan set, so the plan below intersects to empty and nothing is deleted — - // the fail-safe holds even in the confirm→delete window, with no extra branch here. - const PruneScan scan = scanPruneOrphans(book_, owned_); - if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing - - const std::vector plan = pruneDeletePlan(confirmed, scan.orphans); - - // Staleness skip count: entries the user confirmed that are no longer fresh orphans - // (vanished or became referenced between confirm and delete). pruneDeletePlan already - // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting - // de-duplicated entries as stale — that would be dishonest. - const std::size_t uniqueConfirmedCount = - std::unordered_set(confirmed.begin(), confirmed.end()).size(); - result.skippedCount += uniqueConfirmedCount - plan.size(); - - for (const std::string& rel : plan) { - // Reconstruct the absolute path from the resolved bank dir + the entry's file name. - // rel is index-spelled "/"; the name is the tail after '/'. - const std::string::size_type slash = rel.find_last_of('/'); - const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); - if (name.empty()) { ++result.skippedCount; continue; } - const std::string absPath = scan.bankDirAbs + "/" + name; - - const auto szIt = scan.sizeByRel.find(rel); - const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0; - - bool alreadyAbsent = false; - if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) { - ++result.reclaimedCount; - result.reclaimedBytes += bytes; - } else if (alreadyAbsent) { - // File vanished between plan and delete — treat as staleness, same as the - // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it). - ++result.skippedCount; - } else { - ++result.skippedCount; // locked / conversion failure -> recorded, not thrown - } - } - return result; -} - -namespace { - -// Load the Design-View model from a project's view_state key, or return a fresh -// default. An absent/empty key (older project with no view state) yields a -// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, -// never a crash. Malformed JSON is warned and also falls back to default, mirroring -// the bank's malformed-index handling. The whole model round-trips: modes, -// membership, show-both, snapshots, and active mode all ride inside the one blob. -ViewModeModel loadViewModel(ReaProject* proj) { - if (!proj) return ViewModeModel{}; - const std::string viewJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey); - if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default - std::optional loaded = ViewModeModel::deserialize(viewJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n"); - return ViewModeModel{}; - } - return std::move(*loaded); -} - -// Load the tail setting from a project's tail_setting key, or return the default. An -// absent/empty key (older / never-adjusted project) yields the default setting (None / -// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back -// to default, mirroring the bank's and view's malformed handling. -TailSetting loadTailSetting(ReaProject* proj) { - if (!proj) return TailSetting{}; - const std::string tailJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey); - if (tailJson.empty()) return TailSetting{}; // no stored setting -> default - std::optional loaded = deserializeTailSetting(tailJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n"); - return TailSetting{}; - } - return *loaded; -} - -// Load the owned-file manifest from a project's owned_files key, or return an empty -// manifest. An absent/empty key (older / never-captured project) yields an empty -// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to -// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then -// sees an empty ownership record and (safely) attributes nothing until the next capture -// rebuilds it — losing the record degrades safety, never correctness. -OwnedFileManifest loadOwnedManifest(ReaProject* proj) { - if (!proj) return OwnedFileManifest{}; - const std::string ownedJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey); - if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty - std::optional loaded = OwnedFileManifest::deserialize(ownedJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n"); - return OwnedFileManifest{}; - } - return std::move(*loaded); -} - -} // namespace - -void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { - // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the - // single choke point for every load path (prime, project switch/open, forked- - // sibling load), so setting it here — and NOT on the Save-As branch, which keeps - // the in-memory model as-is — makes the signal fire exactly when a fresh view - // model has been installed and its active mode's visibility needs reapplying. - // main.cpp drains it via consumeLoadSignal() on the same tick. - loadPending_ = true; - - // The view model is restored on EVERY load path (peer-symmetry with the bank - // reset below): switching to a project with no view state must clear stale - // in-memory state, not inherit the previous project's. D3 restores MODEL STATE - // only — no visibility/processing is applied here (that is D4). - view_ = loadViewModel(static_cast(proj)); - - // The tail setting is restored on EVERY load path too (peer-symmetry): switching - // to a project with no stored setting must fall back to the default, not inherit - // the previous project's choice (this REPLACES the old session-carry behavior). - tail_ = loadTailSetting(static_cast(proj)); - - // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the - // bank/view/tail resets): switching to a project with no stored manifest must reset - // to empty, not inherit the previous project's ownership record; an undo/redo reload - // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. - owned_ = loadOwnedManifest(static_cast(proj)); - - // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry - // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed - // one as Unknown — both silent, no console warning (a pre-versioning project is not - // an error). getProjExtStateString returns "" for an absent key, which is exactly the - // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. - writingVersion_ = classifyWritingVersion( - proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtVersionKey) - : std::string{}); - - // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with - // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value - // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A - // project switch reads THAT project's counter, not the previous one's; an absent/malformed - // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. - bankGeneration_ = instrument::map::parseBankGeneration( - proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtBankGenKey) - : std::string{}); - - if (!proj) { - book_ = BankBook{}; - return; - } - - // Read both possible sources: the authoritative `banks` blob and the retired-but- - // possibly-still-present legacy `bank_index`. The precedence + migration decision - // (`banks` wins; else the legacy index migrates into the pool; else an empty book) - // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only - // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt - // blob must read as "ignored", not silent loss, mirroring the prior malformed-index - // warning. A malformed `banks` degrades to an empty book and does NOT fall back to - // the stale legacy key (which would resurrect superseded single-bank state). - const std::string banksJson = - getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtBanksKey); - if (!banksJson.empty()) { - std::optional loaded = BankBook::deserialize(banksJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n"); - book_ = BankBook{}; - } else { - book_ = std::move(*loaded); - } - } else { - // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool - // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- - // empty tail; passing "" for banksJson takes exactly that branch. - const std::string legacyJson = - getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtIndexKey); - book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); - } - - // L7 slot migration: seed every bank's display-position SlotMap from its index - // insertion order when the loaded blob carried none (a pre-L7 project -> dense, - // gap-free, visually identical on first post-L7 load), and reconcile a partial map - // (drop stale markers, append unmapped samples) for a blob written by an earlier L7 - // build. One-way: once the book is re-saved the reconciled slot data is authoritative. - // Idempotent, so a fresh empty book is a cheap no-op. - book_.reconcileSlots(); - - // Project-relative resolution is a READ-time concern: every BankModel in the book - // stores only relative paths (invariant, enforced per-bank at add()), and consumers - // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via - // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to - // absolute here — that would break the relative-only invariant and travel-with-.rpp. - // projectDir is threaded through for those consumers; nothing to do at load time - // beyond replacing the in-memory book. - (void)projectDir; -} - -namespace { - -// Ensure a SAVED project carries a stored GUID, minting and writing one if it -// has none yet (a project saved before this feature shipped, or a brand-new -// first save). Returns the effective GUID: the existing one, the freshly minted -// one, or "" for an unsaved project (no .rpp to store ext state into — the same -// gate SetProjExtState/saveToActiveProject already respect on empty path). -// Called from BOTH prime and the Load branch so identity is established the same -// way on every entry to a project (peer-symmetry: no path skips the mint). -std::string ensureProjectGuid(void* proj, const std::string& rppPath, - const std::string& currentGuid) { - if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID - if (!currentGuid.empty()) return currentGuid; - const std::string minted = genProjectGuidString(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtGuidKey, minted.c_str()); - return minted; -} - -} // namespace - -bool ReaSamplerSession::consumeLoadSignal() { - const bool pending = loadPending_; - loadPending_ = false; - return pending; -} - -void ReaSamplerSession::requestReload() { - // Set-only; poll() drains it on the next tick (see the poll() drain block for why - // the read is deferred past the projectconfig callback). Cheap and idempotent — - // multiple undo/redo callbacks before the next tick collapse to one reload. - reloadRequested_ = true; -} - -void ReaSamplerSession::poll() { - std::string rppPath; - void* proj = readActiveProject(rppPath); - const std::string currentGuid = - proj ? getProjExtStateString(static_cast(proj), - projExtNamespace(), kProjExtGuidKey) - : std::string{}; - - if (!primed_) { - // First observation: adopt current identity and load its index, without - // treating it as a "change" (avoids a spurious relocation on startup). - primed_ = true; - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; - reloadRequested_ = false; // priming already loaded — a co-tick request is moot - return; - } - - // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier - // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID, - // and .rpp path — so classifyProjectTransition would return NoOp and never re-read - // ext state, leaving book_/view_ stale after the on-disk ext state rolled back. - // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_ - // one or more ticks ago; by NOW REAPER has finished restoring the project's - // block, so GetProjExtState returns the POST-undo value. Reload from the - // current active project and identity-adopt it (no relocation — the path is - // unchanged), then return. loadFromProject raises loadPending_, so the existing - // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active - // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is - // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect - // an undo (Daniel's directive: the hook drives it, not a poll heuristic). - if (reloadRequested_) { - reloadRequested_ = false; - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; - return; - } - - // Pointer identity is the primary signal: a genuine Save-As keeps the SAME - // ReaProject* (one object saved elsewhere); a tab-switch/open is a different - // object. Passing the bool (not the pointer) keeps the classifier pure. - const bool sameProjectObject = (proj == lastProject_); - const ProjectTransition transition = classifyProjectTransition( - sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath); - - switch (transition) { - case ProjectTransition::NoOp: - return; - - case ProjectTransition::Load: { - // A different project of record is active (open / tab switch / new / - // reopened / recycled pointer / forked sibling). Load ITS index; never - // relocate. - // - // Forked-sibling divergence: gate on `!sameProjectObject` so this fires - // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork - // that copied our GUID and never re-saved (its fresh GUID was runtime- - // only on the sibling we came from). A recycled-pointer Load (step 1: - // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct - // identity. currentGuid == lastGuid_ can only hold here when step 1 did - // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject - // makes that intent load-bearing rather than incidental. Do this BEFORE - // loadFromProject reads the index (order is irrelevant — GUID and - // bank_index are distinct keys — but self-contained is clearest). - if (proj && !sameProjectObject && !currentGuid.empty() && - currentGuid == lastGuid_ && !rppPath.empty()) { - const std::string fresh = genProjectGuidString(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtGuidKey, fresh.c_str()); - MarkProjectDirty(static_cast(proj)); - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = fresh; - lastRppPath_ = rppPath; - return; - } - - // Normal load: establish identity the same way prime does. - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; - return; - } - - case ProjectTransition::SaveAsRelocate: { - // SAME project object + new .rpp path: a genuine Save-As (the pointer - // proves it — a fork tab-switch is a DIFFERENT object and took the Load - // branch above). Relocate the bank folder from the old dir to the new - // one so the wavs sit under the new .rpp and the index's relative paths - // still resolve. Keep the in-memory bank as-is (Save-As copied our ext - // state, the relative paths are unchanged) — do NOT reload. - const std::string oldDir = projectDirOf(lastRppPath_); - const std::string newDir = projectDirOf(rppPath); - const BankRelocation plan = deriveRelocationPlan(oldDir, newDir); - if (plan.needed) { - relocateBankFolder(plan.oldBankDir, plan.newBankDir); - } - - // Save-As duplicated our ext state, so the new project B currently - // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B - // no longer collide on identity when reopened later. Adopt the fresh - // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes - // to the new .rpp on the next normal save / close-prompt. - const std::string fresh = genProjectGuidString(); - if (proj) { - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtGuidKey, fresh.c_str()); - MarkProjectDirty(static_cast(proj)); - } - lastProject_ = proj; // unchanged (same object) — set for symmetry - lastGuid_ = fresh; - lastRppPath_ = rppPath; - return; - } - } -} - -} // namespace reasampler diff --git a/src/persist.h b/src/persist.h index 74a3d37..8baa1ae 100644 --- a/src/persist.h +++ b/src/persist.h @@ -1,342 +1,25 @@ #pragma once #include "core/namespaces.h" -// persist — the REAPER-facing bridge between the in-memory BankModel and project -// ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths). +// persist.h — COMPATIBILITY UMBRELLA (Q-W5). The former persist god-TU split into +// three TUs under shell/persist/ by responsibility: // -// Save: serialize the BankModel JSON -> SetProjExtState under namespace -// "reasampler" (ext state lives inside the .rpp, so the index travels with the -// project for free). -// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory -// BankModel, then resolve each entry's bank file against the CURRENT project -// dir (project-relative resolution — a project opened from a new location still -// finds its bank). -// Save-As: when the project path changes, relocate the physical bank folder so -// the wavs end up under the new .rpp (the index's relative paths stay valid). +// * shell/persist/session.h + session.cpp — the ReaSamplerSession class (lifecycle, +// poll identity-transition detection, the projectconfig undo/redo reload drain). +// * shell/persist/ext_state_io.h + ext_state_io.cpp — the ext-state ↔ JSON +// serialization bridge, key contract, GUID minting, bank-folder relocation. +// * shell/persist/prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION +// AUTHORITY (deleteOrphanFile, file-local; nothing else deletes bytes). // -// The header is REAPER-free (no SDK types leak here): callers interact through a -// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API -// calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip -// and capture_paths (pure) for the path arithmetic it drives. - -#include -#include - -#include "core/version/app_version.h" -#include "core/model/bank_book.h" -#include "core/model/bank_model.h" -#include "ext_keys.h" -#include "core/model/owned_manifest.h" -#include "core/reclaim/prune_reconcile.h" -#include "core/capture/tail_control.h" -#include "core/view/view_mode_model.h" - -namespace reasampler { - -// The ext-state namespace + the WIRE-SHARED key names are the contract between this -// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h -// (pure, REAPER-free) and are included above — not duplicated here. The namespace is -// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() -// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- -// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both -// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace -// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project -// saved by stable shows empty/default state in beta and vice versa; that isolation is the -// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. -// The per-key semantics persist relies on (spellings owned by ext_keys.h): -// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). -// AUTHORITATIVE going forward; the VST reads this key to see the live bank. -// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared -// on save); READ once on load to migrate a legacy project into the pool. -// * kProjExtViewKey : the Design-View ViewModeModel JSON. -// * kProjExtTailKey : the docked panel's TailSetting JSON. -// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() -// tells a Save-As from a recycled-pointer project switch by it). -// All are FOREVER-STABLE once shipped: changing any strands every already-saved -// project's stored state under that key. +// This header re-exports the split APIs so every existing caller (actions.cpp, +// main.cpp, ingest.cpp, the panel TUs, capture shells) keeps compiling untouched — +// Q-W4 is rewriting actions.cpp in parallel, so touching callers this wave is a +// guaranteed conflict. Retiring this umbrella (callers include the split headers +// directly) is Q-W6 cleanup. // -// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this -// is the const char* the SetProjExtState/GetProjExtState calls in persist.cpp pass. Kept -// as an accessor (not a literal) because the string is channel-derived at build time. -inline const char* projExtNamespace() { return extStateNamespace().c_str(); } +// core/namespaces.h stays HERE, not in the split headers/TUs: the unsplit callers +// still reference flat-namespace symbols (TailSetting, PruneReport, BankModel, ...) +// through this include, while the split persist TUs themselves reference real +// namespace homes and are shim-free. -// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument -// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: -// -// owned_files — the owned-file manifest JSON (project-relative files the capture path -// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's -// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT -// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it -// strands every saved project's ownership record (prune falls back to an empty manifest — -// graceful, but the attribution safety net is lost until the next capture rebuilds it). -inline constexpr const char* kProjExtOwnedKey = "owned_files"; - -// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on -// every save, so every saved .rpp records which build produced its state — the seam a -// future within-channel forward migration keys off. An absent key is the explicit -// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. -inline constexpr const char* kProjExtVersionKey = "version"; - -// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence -// against the active REAPER project. One instance lives for the extension's -// lifetime (main.cpp). It tracks -// the project identity it last saw so the timer tick can detect a project load -// (a different project became active) and a Save-As (SAME project, path changed): -// -// * project load -> load the index from ext state, resolve bank paths -// * Save-As (new dir) -> relocate the bank folder under the new .rpp -// -// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of -// record, immune to REAPER recycling a closed project's ReaProject* address) is -// checked FIRST, and the live pointer disambiguates only the same-GUID case — a -// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same -// GUID, same object, new path -> relocate). GUID-first catches pointer recycling -// (a reopened/new project reusing the previous address with a different GUID — the -// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As -// copies our GUID onto a distinct object — the W10 defect that clobbered a bank). -// -// The book itself is exposed for the capture/action layer to mutate; persist -// only reads it on save and replaces it on load. -class ReaSamplerSession { -public: - ReaSamplerSession() = default; - - // The multi-bank book (Phase B): the pool + named banks, each wrapping a - // BankModel, plus the active-bank id. The action layer (B3) creates / renames / - // reorders / deletes banks and moves samples here; the panel (B4) reads it; - // persist serializes it under the `banks` key on save and replaces it on load. - BankBook& book() { return book_; } - const BankBook& book() const { return book_; } - - // The capture add-target: the ACTIVE bank's BankModel (defaults to the pool). - // The capture path adds a captured Sample through this seam, so a capture lands - // in whichever bank is active — the single behavioural change B2 wires in over - // M7/M8 (the capture backends are untouched; only the target index moved). The - // panel/insert readers that displayed the single index continue to read it here - // unchanged; today it resolves to the pool (default active), matching prior - // single-bank behaviour, until B3/B4 let the user switch the active bank. - BankModel& bank() { return book_.activeIndex(); } - const BankModel& bank() const { return book_.activeIndex(); } - - // The in-memory Design-View model. The view/action layer mutates it (tag, - // toggle, snapshot); persist serializes it on save and replaces it on project - // load — exactly as it treats the bank. D3 persists MODEL STATE only; applying - // visibility/processing (reapply-on-open) is D4's job, not this member's. - ViewModeModel& view() { return view_; } - const ViewModeModel& view() const { return view_; } - - // The docked panel's tail setting (mode + manualMs), authoritative here — NOT in - // panel state — so it travels inside the .rpp: persist serializes it on save and - // replaces it on project load exactly as it treats the bank and view model. The - // panel reads/writes it through this seam (bank_panel holds the session), and the - // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for - // an unsaved or pre-feature project (no stored key -> this default survives load). - TailSetting& tail() { return tail_; } - const TailSetting& tail() const { return tail_; } - - // The owned-file manifest (Phase B B-cap): the set of project-relative files the - // capture path itself created. The capture add-path records each created file here - // (main.cpp, alongside the bank add), exactly as it adds the Sample to the active - // bank; persist serializes it under the `owned_files` key on save and replaces it on - // project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it; - // B-cap only writes and persists it (no prune logic here). - OwnedFileManifest& owned() { return owned_; } - const OwnedFileManifest& owned() const { return owned_; } - - // The ReaSampler version that last WROTE the active project, recovered from its - // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no - // stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the - // exact stored string otherwise — all silent, never an error. Replaced on every load - // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved - // or never-loaded session. Exposed so a future migration step (or diagnostics) can - // reason about the origin build without re-reading ext state. - const WritingVersion& writingVersion() const { return writingVersion_; } - - // The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic - // per project: recovered on load (so it continues from the stored value rather than - // resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on - // every saveToActiveProject(). Exposed const for the writer sites to read/log. - std::int64_t bankGeneration() const { return bankGeneration_; } - - // Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes - // what a live instance would PLAY (capture add, re-capture-in-place, sample remove, - // move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create / - // rename / activate / reorder a bank), which change no existing (bankId, sampleId) -> - // content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call - // the same mutation already makes (the counter rides the persist blob, so there is no - // separate write). In-memory only here — cheap and REAPER-free; the persist is the write. - // Over-bumping is safe (a reload that finds unchanged content atomically re-installs the - // same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err - // toward bumping. Idempotent per logical op — call once per mutation, before the persist. - void bumpBankGeneration() { ++bankGeneration_; } - - // Serialize the current book (under the `banks` key), view model, and tail setting - // to the active project's ext state (namespace "reasampler"), and clear the retired - // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. - // Safe to call when there is no active/saved project (it no-ops). - // - // Returns true iff a persist actually happened (an active, SAVED project existed); - // false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a - // caller wrapping this in an undo block skip the block when nothing was written, so - // no dangling no-effect undo entry is opened on an unsaved project. - bool saveToActiveProject(); - - // Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY, - // deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project- - // relative machinery the index/persist use — never a stale absolute path, so it is - // correct across a Save-As relocation), spells every enumerated entry with the index's - // own convention (bankRelativeForName — byte-identical to the capture path's spelling), - // and feeds the R1 pure core with (present, referenced, owned().paths()) where - // `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's - // held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state - // records + the live FX enumeration; sample_usage decides liveness) — a capture any - // live instance holds can never be an orphan, so the prune can never delete it. - // FAIL-SAFE: a present-but-unreadable usage record sets the report's - // abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts. - // Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file - // list. The decision stays in the pure core — this method only enumerates, resolves, - // and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no - // save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file. - // - // Yields an empty report (count 0) when there is no active/saved project or no bank - // folder on disk yet — an unsaved or never-captured project has nothing to reclaim. - PruneReport pruneDryRun() const; - - // The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh - // enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no - // 64-cap display clip) as project-relative index-spelled paths, in enumeration order. - // The R3 action calls this to obtain the exact set it will CONFIRM and then delete - // (pruneDryRun's truncated list is for the console readout; the delete set must be - // complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is - // no active/saved project or no bank folder yet. - std::vector pruneOrphanSet() const; - - // Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path - // in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest. - // Given the orphan set the user was shown and confirmed (`confirmed`, typically the - // full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs - // the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan) - // so a file that vanished or became referenced between confirm and delete is skipped, - // never wrongly deleted — and a newly-appeared orphan the user did NOT see is never - // swept. Deletion routes to the OS trash where a portable move-to-trash is verified - // (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to - // std::filesystem unlink behind this confirm guardrail (see persist.cpp for per-platform - // routing). Non-throwing: every filesystem call uses error_code forms; a per-file - // failure (locked, already gone) is recorded and skipped, never thrown across the C ABI. - // - // Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does - // NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present) - // algebra naturally once it is off disk — no persist write, so no undo-point question - // and no risk to the referenced/owned safety). Writes NO ext-state at all. - // - // No-ops (empty result) when there is no active/saved project, no bank folder, or the - // delete plan is empty (everything went stale). The caller is responsible for having - // shown the confirm; this method does NOT prompt. - PruneDeletionResult pruneReclaim(const std::vector& confirmed) const; - - // Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the - // `assign_request` key, namespace "reasampler"): the extension telling the active - // sampler instance "play THIS sample now." `wire` is the pure assignment_request - // encoding (assignment_request.h); this method only routes the already-encoded value - // to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and - // the encode live in the ingest shell (the pure module) so persist stays a thin bridge. - // - // A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an - // assignment request is a transient "just assigned" signal the instrument reads and - // acts on, so it rides its own key and is written only at ingest time, never on every - // book save. Returns true iff written (an active, SAVED project existed); false on a - // no-active / unsaved project (nothing to write into — the assign is dropped, matching - // the book/manifest quiet-persist idiom the ingest add-path already tolerates). - bool writeAssignmentRequest(const std::string& wire); - - // Poll the active project. Detects a project load (active project changed) - // and a Save-As (active project's .rpp path changed) and reacts accordingly. - // Intended to be driven by REAPER's "timer" register. Idempotent per tick. - // - // Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z - // keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the - // identity classifier below reads it as NoOp and would never re-read ext state. - // The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state - // restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the - // (now-restored) ext state of the current project — before the identity check, so - // the undo is reflected in-session without any content polling. - void poll(); - - // Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext - // state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY - // on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read) - // because the projectconfig callback fires BEFORE REAPER has restored the project's - // block — reading GetProjExtState synchronously there would return the - // PRE-undo value. Draining it on the next timer tick reads the restored value. This - // is REAPER-facing shell state; the request itself carries no REAPER types. - void requestReload(); - - // Load signal for the D4 reapply-on-open glue. poll() raises this whenever it - // (re)loads the view model from a project — prime, a project switch/open, or a - // forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears - // it, so the integration layer (main.cpp) can react by reapplying the saved - // active mode's visibility exactly once, then goes quiet on idle ticks. - // - // Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view - // shell), so there is no persist -> view dependency. main.cpp owns the glue — - // it drives both persist.poll() and view::applyMode, so the reapply wiring lives - // where those two already meet. D3 deliberately deferred exactly this to D4. - bool consumeLoadSignal(); - -private: - BankBook book_; - - // The Design-View model. Default-constructed = Arrange + Design seeded, active - // = Arrange; loadFromProject leaves this default when a project has no stored - // view_state (older project), so an absent key is graceful, not a crash. - ViewModeModel view_; - - // The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it - // to this default when a project has no stored tail_setting key (older / never- - // adjusted project), so an absent key is graceful. Peer to bank_/view_. - TailSetting tail_; - - // The owned-file manifest. Default empty; loadFromProject resets it to empty (or the - // stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to - // a project with no stored manifest must not inherit the previous project's ownership - // record, and an undo that rolled back a capture must re-read the restored manifest so - // the in-memory set matches disk. Absent key -> empty is graceful (older project). - OwnedFileManifest owned_; - - // The writing-version stamp recovered on load (Phase V). Default PreVersioning; - // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so - // switching to a pre-versioning project reports PreVersioning rather than inheriting - // the previous project's stamp. Read-only to consumers via writingVersion(). - WritingVersion writingVersion_; - - // The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path - // from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it - // continues monotonic from the persisted value across reopen and resets cleanly on a - // project switch (a different project's counter, not the previous project's). bumped by - // bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject(). - // Default 0 for an unsaved / never-loaded / pre-S9 session. - std::int64_t bankGeneration_ = 0; - - // The project identity last observed by poll(), used to detect load/Save-As. - // The GUID is the PRIMARY signal (a different stored GUID = a different project - // of record = Load, immune to pointer recycling). The pointer disambiguates the - // same-GUID case (different object = forked sibling -> Load; same object + new - // path -> Save-As) and drives forked-sibling re-divergence; the path tells a - // Save-As from an idle tick. - // Held as void* so the header stays REAPER-free; it is a compared-only opaque - // handle (never dereferenced), so a stale/recycled address is harmless. - void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only) - std::string lastGuid_; // "" until the first saved project is seen - std::string lastRppPath_; // .rpp path last seen for lastProject_ - bool primed_ = false; // false until the first poll() observes state - bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal - bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll - - // Load the book from the given project's ext state (the `banks` key, else the - // legacy `bank_index` key migrated into the pool) and resolve bank paths against - // projectDir at read time. Replaces the in-memory book. Also restores view_, tail_, - // and owned_ from their sibling keys on every load path. projectDir empty -> the - // book is reset to empty (unsaved project has no resolvable banks). - void loadFromProject(void* proj, const std::string& projectDir); -}; - -} // namespace reasampler +#include "shell/persist/ext_state_io.h" +#include "shell/persist/session.h" diff --git a/src/shell/instrument/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp index 91fa451..fc2c3b9 100644 --- a/src/shell/instrument/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -93,21 +93,19 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin // GetProjExtState writes into a caller buffer; the bank blob can be large (many // samples), so grow the buffer until the value fits rather than risk a silent - // truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The - // return value is the value length; if it fits strictly inside the buffer it is - // complete, else grow and retry up to a 16 MB ceiling. - for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(), - buf.data(), cap); - if (rv <= 0) return std::nullopt; // absent / empty key - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) { - return decodeGetProjExtState(rv, s); - } - // else: possibly truncated -> grow and retry. - } - return std::nullopt; // pathologically large (>16 MB) — give up rather than loop + // truncation. The retry policy is the SHARED pure + // instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — one loop for the + // extension's persist/usage reads and this bridge read; the rules cannot drift): + // absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a + // complete value still runs through decodeGetProjExtState (the stale/empty-buffer + // guard) exactly as before. + const auto read = instrument::map::readProjExtStateGrowing( + [&](char* buf, int cap) { + return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap); + }); + if (read.status != instrument::map::GrowingExtStateRead::Status::Complete) + return std::nullopt; // absent / empty key, or pathologically large (>16 MB) + return decodeGetProjExtState(read.apiReturn, read.value); } bool ReaperBridge::writeUsageExtState(const std::string& usageKey, diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp new file mode 100644 index 0000000..f5c7e03 --- /dev/null +++ b/src/shell/persist/ext_state_io.cpp @@ -0,0 +1,403 @@ +// ext_state_io.cpp — the ext-state ↔ JSON serialization half of the persist seam +// (Q-W5 split of the former persist.cpp; see session.h for the TU map and +// ext_state_io.h for the key contract): the session's save/load/assignment-request +// bridge, plus the shared persist_detail helpers (active-project read, growing +// ext-state read, GUID minting, bank-folder relocation) the sibling TUs call. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). +// +// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the +// whole BankBook (pool as bank-zero + named banks) is written under key "banks" +// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on +// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre- +// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so +// the banks travel with the project automatically (CONTEXT.md §Persistence & paths). +// The only thing that does NOT travel for free is the physical bank folder; on +// Save-As to a new directory we relocate it so the indices' relative paths still +// resolve (poll(), session.cpp, executes the relocation this TU implements). +// +// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY +// our own reasampler_bank/ folder. It never touches the user's media, items, or +// other ext-state namespaces. + +#include "shell/persist/ext_state_io.h" + +#include +#include +#include +#include +#include +#include + +#include "shell/persist/persist_internal.h" +#include "shell/persist/session.h" + +#include "core/capture/capture_paths.h" // projectDirOfRpp (pure path arithmetic) +#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) +#include "core/instrument/map/bridge_marshal.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) +#include "core/version/app_version.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetProjExtState +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_SetProjExtState +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_genGuid +#define REAPERAPI_WANT_guidToString +#include "reaper_plugin_functions.h" + +namespace reasampler::persist_detail { + +namespace fs = std::filesystem; + +// Read the active project pointer and its .rpp path in one shot. idx=-1 is the +// current project tab (SDK header line ~1262). The out-buffer receives the full +// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — +// same fact capture.cpp relies on). Returns nullptr proj only when there is no +// active project at all. +void* readActiveProject(std::string& rppPathOut) { + std::vector buf(4096, '\0'); + ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(buf.size())); + rppPathOut.assign(buf.data()); + return proj; +} + +// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> +// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the +// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp +// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths +// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both +// artifacts share one implementation rather than duplicating the parent-of-.rpp step. +std::string projectDirOf(const std::string& rppPath) { + return capture::projectDirOfRpp(rppPath); +} + +// GetProjExtState needs a caller-supplied buffer; the index JSON can be large +// (many samples). The grow-until-strict-fit retry policy is the SHARED pure +// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — the same policy the +// usage_scan and VST-bridge reads run); this wrapper binds the REAPER call and +// folds the terminal cases persist's callers expect: "" for an absent key (a valid +// empty bank, not an error) and a console warning + "" for a value exceeding the +// 16 MB ceiling, so an over-large value reads as "too large to load", not silent +// data loss (mirrors the malformed-JSON warning in loadFromProject). +std::string getProjExtStateString(void* proj, const char* ns, const char* key) { + using instrument::map::GrowingExtStateRead; + const GrowingExtStateRead read = instrument::map::readProjExtStateGrowing( + [&](char* buf, int cap) { + return GetProjExtState(static_cast(proj), ns, key, buf, cap); + }); + switch (read.status) { + case GrowingExtStateRead::Status::Complete: + return read.value; + case GrowingExtStateRead::Status::Absent: + return {}; // absent / empty -> empty bank + case GrowingExtStateRead::Status::Overflow: + break; + } + ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) + + "' exceeds the 16 MB read ceiling -- ignoring (bank not " + "loaded).\n").c_str()); + return {}; +} + +// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. +// guidToString wants a >=64-char destination (SDK header line ~3846). +std::string genProjectGuidString() { + GUID g{}; + genGuid(&g); + char buf[64] = {0}; + guidToString(&g, buf); + return std::string(buf); +} + +// Ensure a SAVED project carries a stored GUID, minting and writing one if it +// has none yet (a project saved before this feature shipped, or a brand-new +// first save). Returns the effective GUID: the existing one, the freshly minted +// one, or "" for an unsaved project (no .rpp to store ext state into — the same +// gate SetProjExtState/saveToActiveProject already respect on empty path). +// Called from BOTH prime and the Load branch so identity is established the same +// way on every entry to a project (peer-symmetry: no path skips the mint). +std::string ensureProjectGuid(void* proj, const std::string& rppPath, + const std::string& currentGuid) { + if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID + if (!currentGuid.empty()) return currentGuid; + const std::string minted = genProjectGuidString(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtGuidKey, minted.c_str()); + return minted; +} + +// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not +// move — see the handoff for the copy-vs-move rationale). Overwrites existing +// files at the destination so a re-save is idempotent. Best-effort: filesystem +// errors are swallowed and reported to the console rather than thrown across the +// REAPER boundary. Returns true if the copy ran (source existed). +bool relocateBankFolder(const std::string& oldBankDir, + const std::string& newBankDir) { + std::error_code ec; + if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) { + return false; // nothing at the old location to relocate + } + if (oldBankDir == newBankDir) return false; // defensive; plan guards this too + + fs::create_directories(newBankDir, ec); + fs::copy(oldBankDir, newBankDir, + fs::copy_options::recursive | fs::copy_options::overwrite_existing, + ec); + if (ec) { + ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir + + "' failed: " + ec.message() + "\n").c_str()); + return false; + } + return true; +} + +} // namespace reasampler::persist_detail + +namespace reasampler { + +using persist_detail::getProjExtStateString; +using persist_detail::readActiveProject; + +bool ReaSamplerSession::saveToActiveProject() { + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj) return false; // no active project — nothing to persist + if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + + // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and + // rides in the `banks` key. + const std::string banksJson = book_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtBanksKey, banksJson.c_str()); + + // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty + // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This + // realizes retirement concretely — after any save, a formerly-legacy project + // carries `banks` and NO `bank_index`, and going forward the legacy key is never + // written. Cheap and idempotent when the key is already absent. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtIndexKey, ""); + + // Additive: the Design-View model rides alongside the banks in its own key. + // Independent write — does not disturb the `banks` blob above. + const std::string viewJson = view_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtViewKey, viewJson.c_str()); + + // Additive: the docked panel's tail setting rides alongside in its own key, so the + // tail choice travels inside the .rpp. Independent write — does not disturb the + // bank_index or view_state above. + const std::string tailJson = capture::serializeTailSetting(tail_); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtTailKey, tailJson.c_str()); + + // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own + // `owned_files` key. Independent write — does not disturb the blobs above. Written + // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, + // and so the manifest and the bank stay in lockstep on disk (both persisted by the + // same saveToActiveProject the capture add-path calls). Uses the channel-derived + // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. + const std::string ownedJson = owned_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtOwnedKey, ownedJson.c_str()); + + // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under + // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty + // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is + // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as + // Stamped on read-back and stays byte-identical to stable regardless of channel; the + // channel is already carried by the isolated namespace (projExtNamespace) this writes to. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtVersionKey, version::stampVersion().c_str()); + + // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME + // seam so the counter and MarkProjectDirty stay paired. The value is whatever + // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so + // every content mutation's own save carries the fresh generation the instrument reads. The + // format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree + // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtBankGenKey, + instrument::map::formatBankGeneration(bankGeneration_).c_str()); + + MarkProjectDirty(static_cast(proj)); + return true; +} + +bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj) return false; // no active project — nothing to signal + if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + + // One-shot write of the ingest assignment request under its own key (S8). Independent + // of the book/view/tail blobs — this is a transient signal to the instrument, not + // session state that must ride every save. Uses the channel-derived namespace + // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta + // instrument reads only a beta extension's assignment requests. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtAssignKey, wire.c_str()); + MarkProjectDirty(static_cast(proj)); + return true; +} + +namespace { + +// Load the Design-View model from a project's view_state key, or return a fresh +// default. An absent/empty key (older project with no view state) yields a +// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, +// never a crash. Malformed JSON is warned and also falls back to default, mirroring +// the bank's malformed-index handling. The whole model round-trips: modes, +// membership, show-both, snapshots, and active mode all ride inside the one blob. +ViewModeModel loadViewModel(ReaProject* proj) { + if (!proj) return ViewModeModel{}; + const std::string viewJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey); + if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default + std::optional loaded = ViewModeModel::deserialize(viewJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n"); + return ViewModeModel{}; + } + return std::move(*loaded); +} + +// Load the tail setting from a project's tail_setting key, or return the default. An +// absent/empty key (older / never-adjusted project) yields the default setting (None / +// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back +// to default, mirroring the bank's and view's malformed handling. +capture::TailSetting loadTailSetting(ReaProject* proj) { + if (!proj) return capture::TailSetting{}; + const std::string tailJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey); + if (tailJson.empty()) return capture::TailSetting{}; // no stored setting -> default + std::optional loaded = + capture::deserializeTailSetting(tailJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n"); + return capture::TailSetting{}; + } + return *loaded; +} + +// Load the owned-file manifest from a project's owned_files key, or return an empty +// manifest. An absent/empty key (older / never-captured project) yields an empty +// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to +// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then +// sees an empty ownership record and (safely) attributes nothing until the next capture +// rebuilds it — losing the record degrades safety, never correctness. +model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { + if (!proj) return model::OwnedFileManifest{}; + const std::string ownedJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey); + if (ownedJson.empty()) return model::OwnedFileManifest{}; // no stored manifest -> empty + std::optional loaded = + model::OwnedFileManifest::deserialize(ownedJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n"); + return model::OwnedFileManifest{}; + } + return std::move(*loaded); +} + +} // namespace + +void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { + // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the + // single choke point for every load path (prime, project switch/open, forked- + // sibling load), so setting it here — and NOT on the Save-As branch, which keeps + // the in-memory model as-is — makes the signal fire exactly when a fresh view + // model has been installed and its active mode's visibility needs reapplying. + // main.cpp drains it via consumeLoadSignal() on the same tick. + loadPending_ = true; + + // The view model is restored on EVERY load path (peer-symmetry with the bank + // reset below): switching to a project with no view state must clear stale + // in-memory state, not inherit the previous project's. D3 restores MODEL STATE + // only — no visibility/processing is applied here (that is D4). + view_ = loadViewModel(static_cast(proj)); + + // The tail setting is restored on EVERY load path too (peer-symmetry): switching + // to a project with no stored setting must fall back to the default, not inherit + // the previous project's choice (this REPLACES the old session-carry behavior). + tail_ = loadTailSetting(static_cast(proj)); + + // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the + // bank/view/tail resets): switching to a project with no stored manifest must reset + // to empty, not inherit the previous project's ownership record; an undo/redo reload + // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. + owned_ = loadOwnedManifest(static_cast(proj)); + + // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry + // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed + // one as Unknown — both silent, no console warning (a pre-versioning project is not + // an error). getProjExtStateString returns "" for an absent key, which is exactly the + // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. + writingVersion_ = version::classifyWritingVersion( + proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey) + : std::string{}); + + // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with + // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value + // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A + // project switch reads THAT project's counter, not the previous one's; an absent/malformed + // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. + bankGeneration_ = instrument::map::parseBankGeneration( + proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey) + : std::string{}); + + if (!proj) { + book_ = BankBook{}; + return; + } + + // Read both possible sources: the authoritative `banks` blob and the retired-but- + // possibly-still-present legacy `bank_index`. The precedence + migration decision + // (`banks` wins; else the legacy index migrates into the pool; else an empty book) + // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only + // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt + // blob must read as "ignored", not silent loss, mirroring the prior malformed-index + // warning. A malformed `banks` degrades to an empty book and does NOT fall back to + // the stale legacy key (which would resurrect superseded single-bank state). + const std::string banksJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey); + if (!banksJson.empty()) { + std::optional loaded = BankBook::deserialize(banksJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n"); + book_ = BankBook{}; + } else { + book_ = std::move(*loaded); + } + } else { + // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool + // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- + // empty tail; passing "" for banksJson takes exactly that branch. + const std::string legacyJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey); + book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); + } + + // L7 slot migration: seed every bank's display-position SlotMap from its index + // insertion order when the loaded blob carried none (a pre-L7 project -> dense, + // gap-free, visually identical on first post-L7 load), and reconcile a partial map + // (drop stale markers, append unmapped samples) for a blob written by an earlier L7 + // build. One-way: once the book is re-saved the reconciled slot data is authoritative. + // Idempotent, so a fresh empty book is a cheap no-op. + book_.reconcileSlots(); + + // Project-relative resolution is a READ-time concern: every BankModel in the book + // stores only relative paths (invariant, enforced per-bank at add()), and consumers + // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via + // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to + // absolute here — that would break the relative-only invariant and travel-with-.rpp. + // projectDir is threaded through for those consumers; nothing to do at load time + // beyond replacing the in-memory book. + (void)projectDir; +} + +} // namespace reasampler diff --git a/src/shell/persist/ext_state_io.h b/src/shell/persist/ext_state_io.h new file mode 100644 index 0000000..ab5a389 --- /dev/null +++ b/src/shell/persist/ext_state_io.h @@ -0,0 +1,59 @@ +#pragma once +// ext_state_io — the ext-state ↔ JSON serialization half of the persist seam +// (Q-W5 split of the former persist god-TU; session.h holds the ReaSamplerSession +// lifecycle, prune_fs.cpp the prune scan + the single file-deletion authority). +// This header owns the persist-side key spellings and the channel-derived +// namespace accessor; the TU (ext_state_io.cpp) implements the session's +// save/load/assignment-request bridge plus the GUID minting and bank-folder +// relocation helpers the poll executes. +// +// The ext-state namespace + the WIRE-SHARED key names are the contract between this +// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h +// (pure, REAPER-free) and are included here — not duplicated. The namespace is +// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() +// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- +// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both +// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace +// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project +// saved by stable shows empty/default state in beta and vice versa; that isolation is the +// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. +// The per-key semantics persist relies on (spellings owned by ext_keys.h): +// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). +// AUTHORITATIVE going forward; the VST reads this key to see the live bank. +// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared +// on save); READ once on load to migrate a legacy project into the pool. +// * kProjExtViewKey : the Design-View ViewModeModel JSON. +// * kProjExtTailKey : the docked panel's TailSetting JSON. +// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() +// tells a Save-As from a recycled-pointer project switch by it). +// All are FOREVER-STABLE once shipped: changing any strands every already-saved +// project's stored state under that key. + +#include "core/version/app_version.h" +#include "ext_keys.h" + +namespace reasampler { + +// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this +// is the const char* the SetProjExtState/GetProjExtState calls pass. Kept as an +// accessor (not a literal) because the string is channel-derived at build time. +inline const char* projExtNamespace() { return version::extStateNamespace().c_str(); } + +// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument +// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: +// +// owned_files — the owned-file manifest JSON (project-relative files the capture path +// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's +// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT +// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it +// strands every saved project's ownership record (prune falls back to an empty manifest — +// graceful, but the attribution safety net is lost until the next capture rebuilds it). +inline constexpr const char* kProjExtOwnedKey = "owned_files"; + +// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on +// every save, so every saved .rpp records which build produced its state — the seam a +// future within-channel forward migration keys off. An absent key is the explicit +// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. +inline constexpr const char* kProjExtVersionKey = "version"; + +} // namespace reasampler diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h new file mode 100644 index 0000000..7d41a0f --- /dev/null +++ b/src/shell/persist/persist_internal.h @@ -0,0 +1,51 @@ +// persist_internal.h — INTERNAL shared helpers for the persist TU family (Q-W5: +// session / ext_state_io / prune_fs, split out of the former persist.cpp god-TU). +// Included ONLY by those three TUs — never a public seam (mirror of the panel's +// panel_state.h / the editor's editor_internal.h internal-seam precedent). Holds the +// former anonymous-namespace helpers that more than one split TU needs; every +// definition lives in ext_state_io.cpp (they are all ext-state / GUID / path / folder +// machinery). Behavior-identical to the pre-split definitions. +// +// REAPER-FREE HEADER: the project handle crosses this seam as the same opaque void* +// the public session header already uses, so no SDK type leaks; the .cpps cast at +// the API boundary. + +#pragma once + +#include + +namespace reasampler::persist_detail { + +// Read the active project pointer and its .rpp path in one shot (EnumProjects(-1)). +// The out-string receives the full .rpp path, EMPTY for a never-saved project (the +// reliable unsaved sentinel). Returns nullptr only when there is no active project. +void* readActiveProject(std::string& rppPathOut); + +// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> +// empty out. Delegates to the pure capture::projectDirOfRpp — the SAME convention +// the VST3 instrument resolves audio paths by. +std::string projectDirOf(const std::string& rppPath); + +// Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns +// "" when the key is absent (a valid empty bank, not an error) and warns on the +// console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored). +// The retry policy itself is the shared pure instrument::map::readProjExtStateGrowing +// (Q-W5 rider, T2-04); this wrapper binds the REAPER call + persist's fold. +std::string getProjExtStateString(void* proj, const char* ns, const char* key); + +// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. +std::string genProjectGuidString(); + +// Ensure a SAVED project carries a stored GUID, minting and writing one if it has +// none yet. Returns the effective GUID, or "" for an unsaved project. Called from +// BOTH prime and the Load branch (peer-symmetry: no path skips the mint). +std::string ensureProjectGuid(void* proj, const std::string& rppPath, + const std::string& currentGuid); + +// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not +// move). Best-effort: filesystem errors are swallowed and reported to the console. +// Returns true if the copy ran (source existed). +bool relocateBankFolder(const std::string& oldBankDir, + const std::string& newBankDir); + +} // namespace reasampler::persist_detail diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp new file mode 100644 index 0000000..f5c7f27 --- /dev/null +++ b/src/shell/persist/prune_fs.cpp @@ -0,0 +1,304 @@ +// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler +// (Q-W5 split of the former persist.cpp; see session.h for the TU map). +// +// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on +// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole +// deletion authority over the bank folder's bytes (the R3 prune; shells removing a +// transient scratch file they themselves just created, e.g. the drop path's temp +// .vstpreset, are self-cleanup, not authority over user data). It is deliberately +// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5 +// split CONCENTRATES the deletion authority here — it must never +// spread (CONTEXT.md §Phase Q deletion-authority isolation; +// docs/product/code-organization.md §7). The safety-critical "which files are +// orphans" decision stays in the pure core (prune_reconcile); this TU only +// enumerates, resolves, stats, and — after the R3 confirm — executes. +// +// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the +// persist_detail helpers (active-project read) and usage_scan (the pS-usage +// instance-hold reads); this TU itself calls no REAPER API directly. + +#include +#include +#include +#include +#include +#include +#include + +// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is +// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK +// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, +// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL +// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the +// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing. +#ifdef _WIN32 +#include +#include +#endif + +#include "shell/persist/persist_internal.h" +#include "shell/persist/session.h" +#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) + +#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder +#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies + +namespace reasampler { + +namespace { + +namespace fs = std::filesystem; + +using persist_detail::projectDirOf; +using persist_detail::readActiveProject; + +// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always +// exact (tallied over the full orphan set), but the enumerated file list handed to the +// console is clipped to this many entries so a project with thousands of orphans does +// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can +// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling. +constexpr std::size_t kPruneListDisplayCap = 64; + +// A fresh enumerate + pure-core prune compute for the active project. Shared by the +// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion +// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path +// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the +// active project, enumerates the folder) but writes nothing. +// +// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty +// when there is no active/saved project, no project dir, or no folder on +// disk yet -> the caller treats an empty dir as "nothing to reclaim". +// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration +// order, untruncated. The pure core decides; this only supplies inputs. +// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd). +// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could +// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY — +// the prune must halt rather than proceed with degraded protection. +// An empty orphan set is itself the delete-side guarantee (every +// consumer of this scan deletes at most `orphans ∩ ...`), the flag is +// what lets the action TELL the user instead of claiming "no orphans". +struct PruneScan { + std::string bankDirAbs; + std::vector orphans; + std::unordered_map sizeByRel; + bool abortedUnreadableUsage = false; + std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage +}; + +// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem +// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI. +PruneScan scanPruneOrphans(const BankBook& book, + const model::OwnedFileManifest& owned) { + PruneScan scan; + + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan + + // Resolve the CURRENT bank folder the same way the index does (M4): project dir of + // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a + // Save-As relocation is followed automatically. resolveBankFile is the shared M4 + // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder. + const std::string projectDir = projectDirOf(rppPath); + const std::string bankDir = + capture::resolveBankFile(projectDir, capture::kBankSubfolder); + if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan + + std::error_code ec; + if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) { + return scan; // no bank folder captured yet -> nothing to reclaim + } + + // Enumerate the folder into project-relative index-spelled paths, spelled the SAME + // way the capture path spelled them (bankRelativeForName == deriveBankPaths's + // convention) so the pure core's exact-string match lines up with referencedPaths() + // and the manifest. Non-recursive: the bank folder is flat (capture writes files + // directly here); skip any subdirectory. Size is stat'd here and cached by relative + // path so the report's byte tally reuses the same on-disk read. + // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration + // failure (file removed, permission flip) breaks out with a best-effort partial list + // rather than propagating std::filesystem_error across REAPER's C ABI. + std::vector present; + fs::directory_iterator it(bankDir, ec); + for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { + const auto& entry = *it; + std::error_code reg_ec; + if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials + const std::string name = entry.path().filename().string(); + const std::string rel = capture::bankRelativeForName(name); + if (rel.empty()) continue; + present.push_back(rel); + std::error_code sz_ec; + const std::uintmax_t sz = entry.file_size(sz_ec); + scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); + } + + // The decision lives in the pure core — read-only inputs from the book and manifest. + // referencedPaths() unions across the whole book (pool included); owned().paths() is + // the manifest set. pS-usage: the referenced set additionally unions every LIVE + // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance + // rsusage_* records + the live FX enumeration; sample_usage decides liveness, + // including the protect-all net when zero instances were identified) — a capture + // any live instance holds can NEVER be an orphan, even when its bank entry was + // deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY, + // preserving this scan's no-write contract. This shell only enumerates, resolves, + // and stats. + scan.bankDirAbs = bankDir; + const UsageScanResult usage = liveInstanceHeldPaths(proj); + if (usage.abortPrune) { + // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the + // protected set is unknowable. Compute NO orphans — every downstream consumer + // (dry-run report, confirm set, fresh-recompute delete plan) then deletes + // nothing. The flag + key names surface the reason so the action can name each + // offending key for operator recovery. + scan.abortedUnreadableUsage = true; + scan.offendingUsageKeys = usage.offendingKeys; + return scan; + } + scan.orphans = reclaim::pruneOrphans( + present, + reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths), + owned.paths()); + return scan; +} + +// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the +// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases: +// * `outAlreadyAbsent` set true — the file was already gone before we touched it; +// the caller folds this into the stale/staleness tally, NOT reclaimedCount. +// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error); +// the caller folds this into skippedCount. +// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception +// may cross the C ABI. +// +// Per-platform routing: +// * Windows — SHFileOperationW(FO_DELETE, pFrom=, FOF_ALLOWUNDO | +// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the +// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our +// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true. +// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this +// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3 +// confirm guardrail. `outUsedTrash` left as-is (false). +bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, + bool& outAlreadyAbsent) { +#ifdef _WIN32 + // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string. + // SHFileOperation's pFrom is a list; a single path still needs the extra terminating + // NUL. Backslashes are required (shell APIs reject forward slashes in some cases). + std::string win = absPath; + for (char& c : win) if (c == '/') c = '\\'; + + const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); + if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false) + std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL + MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); + // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen] + // makes it the double-NUL-terminated single-element list SHFileOperation wants. + + SHFILEOPSTRUCTW op{}; + op.hwnd = nullptr; + op.wFunc = FO_DELETE; + op.pFrom = wbuf.data(); + op.pTo = nullptr; + op.fFlags = static_cast(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | + FOF_SILENT | FOF_NOERRORUI); + const int rv = SHFileOperationW(&op); + if (rv == 0 && !op.fAnyOperationsAborted) { + outUsedTrash = true; + return true; // deleted this call -> reclaimed + } + // SHFileOperation failed (e.g. file already gone yields a nonzero code on some + // versions, or a lock). Distinguish "already absent" from a real failure so the + // caller can tally them separately (absent -> staleness skip; failure -> locked skip). + std::error_code ec; + if (!fs::exists(absPath, ec)) { + outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + } + return false; +#else + // No portable trash surface on SWELL platforms -> hard unlink behind the confirm. + std::error_code ec; + const bool removed = fs::remove(absPath, ec); + if (removed) return true; // deleted this call -> reclaimed + if (ec) return false; // a real failure (locked / permission) -> skip + // remove returned false with no error == the file did not exist -> already gone. + outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + return false; +#endif +} + +} // namespace + +reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { + const PruneScan scan = scanPruneOrphans(book_, owned_); + // buildPruneReport tallies count / byte-sum / display-truncation — no report logic + // re-implemented here. An empty scan (no project / no folder) yields a zero report. + reclaim::PruneReport report = + reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); + // pS-usage fail-safe: surface the unreadable-record abort so the action halts with + // an explicit message instead of reporting "no orphaned files" (the count IS zero — + // the scan computed nothing — but the user must know the prune refused to run). + // The offending key names propagate so the action can name each one for recovery. + report.abortedUnreadableUsage = scan.abortedUnreadableUsage; + report.offendingUsageKeys = scan.offendingUsageKeys; + return report; +} + +std::vector ReaSamplerSession::pruneOrphanSet() const { + return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated +} + +reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( + const std::vector& confirmed) const { + reclaim::PruneDeletionResult result; + + // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets + // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became + // referenced between confirm and delete drops out of freshOrphans and is skipped; a + // newly-appeared orphan not in `confirmed` is never swept without its own confirm. + // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced + // and NO hand-dropped file — the R-C/R-D safety survives the recompute. + // pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an + // EMPTY orphan set, so the plan below intersects to empty and nothing is deleted — + // the fail-safe holds even in the confirm→delete window, with no extra branch here. + const PruneScan scan = scanPruneOrphans(book_, owned_); + if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing + + const std::vector plan = + reclaim::pruneDeletePlan(confirmed, scan.orphans); + + // Staleness skip count: entries the user confirmed that are no longer fresh orphans + // (vanished or became referenced between confirm and delete). pruneDeletePlan already + // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting + // de-duplicated entries as stale — that would be dishonest. + const std::size_t uniqueConfirmedCount = + std::unordered_set(confirmed.begin(), confirmed.end()).size(); + result.skippedCount += uniqueConfirmedCount - plan.size(); + + for (const std::string& rel : plan) { + // Reconstruct the absolute path from the resolved bank dir + the entry's file name. + // rel is index-spelled "/"; the name is the tail after '/'. + const std::string::size_type slash = rel.find_last_of('/'); + const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); + if (name.empty()) { ++result.skippedCount; continue; } + const std::string absPath = scan.bankDirAbs + "/" + name; + + const auto szIt = scan.sizeByRel.find(rel); + const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0; + + bool alreadyAbsent = false; + if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) { + ++result.reclaimedCount; + result.reclaimedBytes += bytes; + } else if (alreadyAbsent) { + // File vanished between plan and delete — treat as staleness, same as the + // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it). + ++result.skippedCount; + } else { + ++result.skippedCount; // locked / conversion failure -> recorded, not thrown + } + } + return result; +} + +} // namespace reasampler diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp new file mode 100644 index 0000000..f84b664 --- /dev/null +++ b/src/shell/persist/session.cpp @@ -0,0 +1,222 @@ +// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (Q-W5 +// split of the former persist.cpp; see session.h for the TU map): the poll-driven +// identity-transition detection and the deferred undo/redo reload drain. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). +// +// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): +// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active +// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext +// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the +// secondary disambiguator (classifyProjectTransition owns the exact order): +// * different stored GUID -> a different project of record -> LOAD its index; +// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's +// address, so a reopened/new project can present the previous pointer with a +// different GUID), new/unsaved<->saved, and switching between distinct saved +// projects. +// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via +// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings +// diverge going forward. +// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new +// location -> relocate the bank folder from the old dir to the new one, then +// re-GUID. +// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) +// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a +// fork and its parent share a GUID on disk; switching between them read as a +// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer +// RECYCLING — a reopened/new project reusing the previous project's address read +// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first +// catches recycling; the pointer then separates a fork (same GUID, different +// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). +// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` +// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free +// and testable; poll() executes the verdict. +// +// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no +// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not +// a cross-open identity), so we MINT one with genGuid/guidToString and store it +// under kProjExtGuidKey (ext_state_io.cpp owns the minting helpers). On Save-As +// REAPER copies the whole .rpp incl. our ext state, so the new project initially +// shares the old GUID; poll() re-GUIDs it (after relocating, or on the forked- +// sibling Load branch) so identities diverge. +// +// Rationale for the timer: the brief mandates ext-state storage (rules out the +// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with +// ext-state while covering identity-transition load + Save-As detection in one +// place. +// +// DIVISION OF LABOUR (R-B undo): +// * Identity-transition poll (this file, classifyProjectTransition) owns +// open / tab-switch / new / forked-sibling / Save-As-relocation — every case +// where the project OF RECORD changes. +// * The `projectconfig` hook (main.cpp registers project_config_extension_t; +// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project +// identity is unchanged but its ext state rolled back/forward on disk. The +// identity poll sees NoOp there and would never re-read ext state, so the hook +// requests a reload (requestReload) that poll() drains on the next tick, once +// REAPER has restored the block. See requestReload / the poll drain. +// The hook fires on undo AND redo (isUndo true for both), and on normal open +// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open +// flows solely through the identity-transition Load path and never double-loads. + +#include "shell/persist/session.h" + +#include + +#include "shell/persist/ext_state_io.h" +#include "shell/persist/persist_internal.h" + +#include "core/capture/capture_paths.h" // classifyProjectTransition / deriveRelocationPlan (pure) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_SetProjExtState +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using persist_detail::ensureProjectGuid; +using persist_detail::genProjectGuidString; +using persist_detail::getProjExtStateString; +using persist_detail::projectDirOf; +using persist_detail::readActiveProject; +using persist_detail::relocateBankFolder; + +bool ReaSamplerSession::consumeLoadSignal() { + const bool pending = loadPending_; + loadPending_ = false; + return pending; +} + +void ReaSamplerSession::requestReload() { + // Set-only; poll() drains it on the next tick (see the poll() drain block for why + // the read is deferred past the projectconfig callback). Cheap and idempotent — + // multiple undo/redo callbacks before the next tick collapse to one reload. + reloadRequested_ = true; +} + +void ReaSamplerSession::poll() { + std::string rppPath; + void* proj = readActiveProject(rppPath); + const std::string currentGuid = + proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtGuidKey) + : std::string{}; + + if (!primed_) { + // First observation: adopt current identity and load its index, without + // treating it as a "change" (avoids a spurious relocation on startup). + primed_ = true; + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); + lastRppPath_ = rppPath; + reloadRequested_ = false; // priming already loaded — a co-tick request is moot + return; + } + + // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier + // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID, + // and .rpp path — so classifyProjectTransition would return NoOp and never re-read + // ext state, leaving book_/view_ stale after the on-disk ext state rolled back. + // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_ + // one or more ticks ago; by NOW REAPER has finished restoring the project's + // block, so GetProjExtState returns the POST-undo value. Reload from the + // current active project and identity-adopt it (no relocation — the path is + // unchanged), then return. loadFromProject raises loadPending_, so the existing + // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active + // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is + // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect + // an undo (Daniel's directive: the hook drives it, not a poll heuristic). + if (reloadRequested_) { + reloadRequested_ = false; + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); + lastRppPath_ = rppPath; + return; + } + + // Pointer identity is the primary signal: a genuine Save-As keeps the SAME + // ReaProject* (one object saved elsewhere); a tab-switch/open is a different + // object. Passing the bool (not the pointer) keeps the classifier pure. + const bool sameProjectObject = (proj == lastProject_); + const capture::ProjectTransition transition = capture::classifyProjectTransition( + sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath); + + switch (transition) { + case capture::ProjectTransition::NoOp: + return; + + case capture::ProjectTransition::Load: { + // A different project of record is active (open / tab switch / new / + // reopened / recycled pointer / forked sibling). Load ITS index; never + // relocate. + // + // Forked-sibling divergence: gate on `!sameProjectObject` so this fires + // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork + // that copied our GUID and never re-saved (its fresh GUID was runtime- + // only on the sibling we came from). A recycled-pointer Load (step 1: + // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct + // identity. currentGuid == lastGuid_ can only hold here when step 1 did + // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject + // makes that intent load-bearing rather than incidental. Do this BEFORE + // loadFromProject reads the index (order is irrelevant — GUID and + // bank_index are distinct keys — but self-contained is clearest). + if (proj && !sameProjectObject && !currentGuid.empty() && + currentGuid == lastGuid_ && !rppPath.empty()) { + const std::string fresh = genProjectGuidString(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(proj)); + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = fresh; + lastRppPath_ = rppPath; + return; + } + + // Normal load: establish identity the same way prime does. + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); + lastRppPath_ = rppPath; + return; + } + + case capture::ProjectTransition::SaveAsRelocate: { + // SAME project object + new .rpp path: a genuine Save-As (the pointer + // proves it — a fork tab-switch is a DIFFERENT object and took the Load + // branch above). Relocate the bank folder from the old dir to the new + // one so the wavs sit under the new .rpp and the index's relative paths + // still resolve. Keep the in-memory bank as-is (Save-As copied our ext + // state, the relative paths are unchanged) — do NOT reload. + const std::string oldDir = projectDirOf(lastRppPath_); + const std::string newDir = projectDirOf(rppPath); + const capture::BankRelocation plan = + capture::deriveRelocationPlan(oldDir, newDir); + if (plan.needed) { + relocateBankFolder(plan.oldBankDir, plan.newBankDir); + } + + // Save-As duplicated our ext state, so the new project B currently + // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B + // no longer collide on identity when reopened later. Adopt the fresh + // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes + // to the new .rpp on the next normal save / close-prompt. + const std::string fresh = genProjectGuidString(); + if (proj) { + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(proj)); + } + lastProject_ = proj; // unchanged (same object) — set for symmetry + lastGuid_ = fresh; + lastRppPath_ = rppPath; + return; + } + } +} + +} // namespace reasampler diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h new file mode 100644 index 0000000..b9c7bc7 --- /dev/null +++ b/src/shell/persist/session.h @@ -0,0 +1,311 @@ +#pragma once +// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of +// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & +// paths). One class, three implementation TUs by responsibility: +// +// * session.cpp — poll() (identity-transition detection: load / Save-As / +// forked sibling / recycled pointer) + the deferred undo/redo reload drain +// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook) +// + the D4 load signal. +// * ext_state_io.cpp — saveToActiveProject / loadFromProject / +// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID +// minting and bank-folder relocation (see ext_state_io.h for the key contract). +// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan +// and THE SINGLE FILE-DELETION AUTHORITY in ReaSampler (deleteOrphanFile via +// SHFileOperationW). Nothing else in the system deletes bytes. +// +// Save: serialize the BankModel JSON -> SetProjExtState under namespace +// "reasampler" (ext state lives inside the .rpp, so the index travels with the +// project for free). +// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory +// BankModel, then resolve each entry's bank file against the CURRENT project +// dir (project-relative resolution — a project opened from a new location still +// finds its bank). +// Save-As: when the project path changes, relocate the physical bank folder so +// the wavs end up under the new .rpp (the index's relative paths stay valid). +// +// The header is REAPER-free (no SDK types leak here): callers interact through a +// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API +// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip +// and capture_paths (pure) for the path arithmetic it drives. + +#include +#include +#include + +#include "core/capture/tail_control.h" +#include "core/model/bank_book.h" +#include "core/model/bank_model.h" +#include "core/model/owned_manifest.h" +#include "core/reclaim/prune_reconcile.h" +#include "core/version/app_version.h" +#include "core/view/view_mode_model.h" + +namespace reasampler { + +// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence +// against the active REAPER project. One instance lives for the extension's +// lifetime (main.cpp). It tracks +// the project identity it last saw so the timer tick can detect a project load +// (a different project became active) and a Save-As (SAME project, path changed): +// +// * project load -> load the index from ext state, resolve bank paths +// * Save-As (new dir) -> relocate the bank folder under the new .rpp +// +// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of +// record, immune to REAPER recycling a closed project's ReaProject* address) is +// checked FIRST, and the live pointer disambiguates only the same-GUID case — a +// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same +// GUID, same object, new path -> relocate). GUID-first catches pointer recycling +// (a reopened/new project reusing the previous address with a different GUID — the +// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As +// copies our GUID onto a distinct object — the W10 defect that clobbered a bank). +// +// The book itself is exposed for the capture/action layer to mutate; persist +// only reads it on save and replaces it on load. +class ReaSamplerSession { +public: + ReaSamplerSession() = default; + + // The multi-bank book (Phase B): the pool + named banks, each wrapping a + // BankModel, plus the active-bank id. The action layer (B3) creates / renames / + // reorders / deletes banks and moves samples here; the panel (B4) reads it; + // persist serializes it under the `banks` key on save and replaces it on load. + BankBook& book() { return book_; } + const BankBook& book() const { return book_; } + + // The capture add-target: the ACTIVE bank's BankModel (defaults to the pool). + // The capture path adds a captured Sample through this seam, so a capture lands + // in whichever bank is active — the single behavioural change B2 wires in over + // M7/M8 (the capture backends are untouched; only the target index moved). The + // panel/insert readers that displayed the single index continue to read it here + // unchanged; today it resolves to the pool (default active), matching prior + // single-bank behaviour, until B3/B4 let the user switch the active bank. + model::BankModel& bank() { return book_.activeIndex(); } + const model::BankModel& bank() const { return book_.activeIndex(); } + + // The in-memory Design-View model. The view/action layer mutates it (tag, + // toggle, snapshot); persist serializes it on save and replaces it on project + // load — exactly as it treats the bank. D3 persists MODEL STATE only; applying + // visibility/processing (reapply-on-open) is D4's job, not this member's. + ViewModeModel& view() { return view_; } + const ViewModeModel& view() const { return view_; } + + // The docked panel's tail setting (mode + manualMs), authoritative here — NOT in + // panel state — so it travels inside the .rpp: persist serializes it on save and + // replaces it on project load exactly as it treats the bank and view model. The + // panel reads/writes it through this seam (bank_panel holds the session), and the + // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for + // an unsaved or pre-feature project (no stored key -> this default survives load). + capture::TailSetting& tail() { return tail_; } + const capture::TailSetting& tail() const { return tail_; } + + // The owned-file manifest (Phase B B-cap): the set of project-relative files the + // capture path itself created. The capture add-path records each created file here + // (main.cpp, alongside the bank add), exactly as it adds the Sample to the active + // bank; persist serializes it under the `owned_files` key on save and replaces it on + // project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it; + // B-cap only writes and persists it (no prune logic here). + model::OwnedFileManifest& owned() { return owned_; } + const model::OwnedFileManifest& owned() const { return owned_; } + + // The ReaSampler version that last WROTE the active project, recovered from its + // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no + // stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the + // exact stored string otherwise — all silent, never an error. Replaced on every load + // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved + // or never-loaded session. Exposed so a future migration step (or diagnostics) can + // reason about the origin build without re-reading ext state. + const version::WritingVersion& writingVersion() const { return writingVersion_; } + + // The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic + // per project: recovered on load (so it continues from the stored value rather than + // resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on + // every saveToActiveProject(). Exposed const for the writer sites to read/log. + std::int64_t bankGeneration() const { return bankGeneration_; } + + // Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes + // what a live instance would PLAY (capture add, re-capture-in-place, sample remove, + // move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create / + // rename / activate / reorder a bank), which change no existing (bankId, sampleId) -> + // content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call + // the same mutation already makes (the counter rides the persist blob, so there is no + // separate write). In-memory only here — cheap and REAPER-free; the persist is the write. + // Over-bumping is safe (a reload that finds unchanged content atomically re-installs the + // same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err + // toward bumping. Idempotent per logical op — call once per mutation, before the persist. + void bumpBankGeneration() { ++bankGeneration_; } + + // Serialize the current book (under the `banks` key), view model, and tail setting + // to the active project's ext state (namespace "reasampler"), and clear the retired + // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. + // Safe to call when there is no active/saved project (it no-ops). + // + // Returns true iff a persist actually happened (an active, SAVED project existed); + // false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a + // caller wrapping this in an undo block skip the block when nothing was written, so + // no dangling no-effect undo entry is opened on an unsaved project. + bool saveToActiveProject(); + + // Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY, + // deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project- + // relative machinery the index/persist use — never a stale absolute path, so it is + // correct across a Save-As relocation), spells every enumerated entry with the index's + // own convention (bankRelativeForName — byte-identical to the capture path's spelling), + // and feeds the R1 pure core with (present, referenced, owned().paths()) where + // `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's + // held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state + // records + the live FX enumeration; sample_usage decides liveness) — a capture any + // live instance holds can never be an orphan, so the prune can never delete it. + // FAIL-SAFE: a present-but-unreadable usage record sets the report's + // abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts. + // Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file + // list. The decision stays in the pure core — this method only enumerates, resolves, + // and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no + // save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file. + // + // Yields an empty report (count 0) when there is no active/saved project or no bank + // folder on disk yet — an unsaved or never-captured project has nothing to reclaim. + reclaim::PruneReport pruneDryRun() const; + + // The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh + // enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no + // 64-cap display clip) as project-relative index-spelled paths, in enumeration order. + // The R3 action calls this to obtain the exact set it will CONFIRM and then delete + // (pruneDryRun's truncated list is for the console readout; the delete set must be + // complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is + // no active/saved project or no bank folder yet. + std::vector pruneOrphanSet() const; + + // Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path + // in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest. + // Given the orphan set the user was shown and confirmed (`confirmed`, typically the + // full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs + // the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan) + // so a file that vanished or became referenced between confirm and delete is skipped, + // never wrongly deleted — and a newly-appeared orphan the user did NOT see is never + // swept. Deletion routes to the OS trash where a portable move-to-trash is verified + // (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to + // std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for + // per-platform routing). Non-throwing: every filesystem call uses error_code forms; a + // per-file failure (locked, already gone) is recorded and skipped, never thrown across + // the C ABI. + // + // Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does + // NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present) + // algebra naturally once it is off disk — no persist write, so no undo-point question + // and no risk to the referenced/owned safety). Writes NO ext-state at all. + // + // No-ops (empty result) when there is no active/saved project, no bank folder, or the + // delete plan is empty (everything went stale). The caller is responsible for having + // shown the confirm; this method does NOT prompt. + reclaim::PruneDeletionResult pruneReclaim( + const std::vector& confirmed) const; + + // Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the + // `assign_request` key, namespace "reasampler"): the extension telling the active + // sampler instance "play THIS sample now." `wire` is the pure assignment_request + // encoding (assignment_request.h); this method only routes the already-encoded value + // to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and + // the encode live in the ingest shell (the pure module) so persist stays a thin bridge. + // + // A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an + // assignment request is a transient "just assigned" signal the instrument reads and + // acts on, so it rides its own key and is written only at ingest time, never on every + // book save. Returns true iff written (an active, SAVED project existed); false on a + // no-active / unsaved project (nothing to write into — the assign is dropped, matching + // the book/manifest quiet-persist idiom the ingest add-path already tolerates). + bool writeAssignmentRequest(const std::string& wire); + + // Poll the active project. Detects a project load (active project changed) + // and a Save-As (active project's .rpp path changed) and reacts accordingly. + // Intended to be driven by REAPER's "timer" register. Idempotent per tick. + // + // Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z + // keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the + // identity classifier below reads it as NoOp and would never re-read ext state. + // The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state + // restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the + // (now-restored) ext state of the current project — before the identity check, so + // the undo is reflected in-session without any content polling. + void poll(); + + // Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext + // state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY + // on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read) + // because the projectconfig callback fires BEFORE REAPER has restored the project's + // block — reading GetProjExtState synchronously there would return the + // PRE-undo value. Draining it on the next timer tick reads the restored value. This + // is REAPER-facing shell state; the request itself carries no REAPER types. + void requestReload(); + + // Load signal for the D4 reapply-on-open glue. poll() raises this whenever it + // (re)loads the view model from a project — prime, a project switch/open, or a + // forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears + // it, so the integration layer (main.cpp) can react by reapplying the saved + // active mode's visibility exactly once, then goes quiet on idle ticks. + // + // Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view + // shell), so there is no persist -> view dependency. main.cpp owns the glue — + // it drives both persist.poll() and view::applyMode, so the reapply wiring lives + // where those two already meet. D3 deliberately deferred exactly this to D4. + bool consumeLoadSignal(); + +private: + BankBook book_; + + // The Design-View model. Default-constructed = Arrange + Design seeded, active + // = Arrange; loadFromProject leaves this default when a project has no stored + // view_state (older project), so an absent key is graceful, not a crash. + ViewModeModel view_; + + // The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it + // to this default when a project has no stored tail_setting key (older / never- + // adjusted project), so an absent key is graceful. Peer to bank_/view_. + capture::TailSetting tail_; + + // The owned-file manifest. Default empty; loadFromProject resets it to empty (or the + // stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to + // a project with no stored manifest must not inherit the previous project's ownership + // record, and an undo that rolled back a capture must re-read the restored manifest so + // the in-memory set matches disk. Absent key -> empty is graceful (older project). + model::OwnedFileManifest owned_; + + // The writing-version stamp recovered on load (Phase V). Default PreVersioning; + // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so + // switching to a pre-versioning project reports PreVersioning rather than inheriting + // the previous project's stamp. Read-only to consumers via writingVersion(). + version::WritingVersion writingVersion_; + + // The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path + // from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it + // continues monotonic from the persisted value across reopen and resets cleanly on a + // project switch (a different project's counter, not the previous one's). bumped by + // bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject(). + // Default 0 for an unsaved / never-loaded / pre-S9 session. + std::int64_t bankGeneration_ = 0; + + // The project identity last observed by poll(), used to detect load/Save-As. + // The GUID is the PRIMARY signal (a different stored GUID = a different project + // of record = Load, immune to pointer recycling). The pointer disambiguates the + // same-GUID case (different object = forked sibling -> Load; same object + new + // path -> Save-As) and drives forked-sibling re-divergence; the path tells a + // Save-As from an idle tick. + // Held as void* so the header stays REAPER-free; it is a compared-only opaque + // handle (never dereferenced), so a stale/recycled address is harmless. + void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only) + std::string lastGuid_; // "" until the first saved project is seen + std::string lastRppPath_; // .rpp path last seen for lastProject_ + bool primed_ = false; // false until the first poll() observes state + bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal + bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll + + // Load the book from the given project's ext state (the `banks` key, else the + // legacy `bank_index` key migrated into the pool) and resolve bank paths against + // projectDir at read time. Replaces the in-memory book. Also restores view_, tail_, + // and owned_ from their sibling keys on every load path. projectDir empty -> the + // book is reset to empty (unsaved project has no resolvable banks). + void loadFromProject(void* proj, const std::string& projectDir); +}; + +} // namespace reasampler diff --git a/src/shell/persist/usage_scan.cpp b/src/shell/persist/usage_scan.cpp index 416ab52..82adb98 100644 --- a/src/shell/persist/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -27,6 +27,7 @@ #include #include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles) +#include "core/instrument/map/bridge_marshal.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) #include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix #include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex #include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) @@ -166,22 +167,23 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { return false; } -// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with -// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the -// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large -// (> 16 MB give-up). The caller only queries keys the enumeration just listed, so a -// nullopt here is a PRESENT-BUT-UNREADABLE record: it folds to abortPrune (fail-safe — -// silently reduced protection is the delete direction). +// Growing GetProjExtState read: the usage record scales with the hold count, so a +// fixed buffer risks a truncated decode. The retry policy is the SHARED pure +// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — one loop for persist, +// this prune-safety-adjacent read, and the VST bridge; the rules cannot drift). +// Returns nullopt when the key cannot be read WHOLE — absent-after-enumeration +// (rv <= 0) or pathologically large (> 16 MB give-up). The caller only queries keys +// the enumeration just listed, so a nullopt here is a PRESENT-BUT-UNREADABLE record: +// it folds to abortPrune (fail-safe — silently reduced protection is the delete +// direction). std::optional readExtStateValue(ReaProject* proj, const char* key) { - for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap); - if (rv <= 0) return std::nullopt; - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) return s; - // else possibly truncated -> grow and retry - } - return std::nullopt; // > 16 MB — unreadable whole, never "absent" + using instrument::map::GrowingExtStateRead; + const GrowingExtStateRead read = instrument::map::readProjExtStateGrowing( + [&](char* buf, int cap) { + return GetProjExtState(proj, kProjExtNamespace(), key, buf, cap); + }); + if (read.status != GrowingExtStateRead::Status::Complete) return std::nullopt; + return read.value; } } // namespace diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index 926f1d2..04730cd 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -6,11 +6,17 @@ // Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer // guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4 // (the instrument now parses the bank through the shared bank_book JSON path), so its -// cases are gone with it. +// cases are gone with it. Q-W5 (rider T2-04) adds readProjExtStateGrowing — the ONE +// grow-loop retry policy shared by persist / usage_scan / reaper_bridge — covered +// against a fake read: absent, small-fit, grow-then-fit, empty-complete (composed with +// the decode guard), and the 16 MB overflow give-up. The overflow case is +// prune-safety-adjacent (usage_scan folds it to abortPrune), so it is pinned here. #include "../src/core/instrument/map/bridge_marshal.h" #include +#include +#include using namespace reasampler; using namespace reasampler::instrument::map; @@ -46,12 +52,100 @@ static void testDecodeEmptyBuffer() { CHECK(!v.has_value()); } +// --- readProjExtStateGrowing (the T2-04 shared grow-loop policy) ------------- + +// A fake GetProjExtState: honors the buffer contract (writes at most cap-1 chars + +// NUL — REAPER clips to the caller's capacity) and returns the stored value's length. +static int fakeRead(const std::string& stored, char* buf, int cap) { + const std::size_t n = + stored.size() < static_cast(cap - 1) + ? stored.size() + : static_cast(cap - 1); + std::memcpy(buf, stored.data(), n); + buf[n] = '\0'; + return static_cast(stored.size()); +} + +static void testGrowingAbsent() { + // rv <= 0 on the first attempt: the key holds no value — Absent, no retry. + int calls = 0; + const auto r = readProjExtStateGrowing([&](char*, int) { + ++calls; + return 0; + }); + CHECK(r.status == GrowingExtStateRead::Status::Absent); + CHECK(r.apiReturn == 0); + CHECK(calls == 1); +} + +static void testGrowingSmallValueFitsFirstAttempt() { + const std::string stored = "hello"; + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + return fakeRead(stored, buf, cap); + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value == stored); + CHECK(r.apiReturn == 5); + CHECK(calls == 1); +} + +static void testGrowingRetriesUntilStrictFit() { + // A value whose C string exactly fills the first buffer (size+1 == cap) is + // AMBIGUOUS — it may have been clipped — so the policy must retry at the next + // capacity, where it fits strictly and returns whole. + const std::string stored(static_cast((1 << 16) - 1), 'x'); + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + return fakeRead(stored, buf, cap); + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value == stored); + CHECK(calls == 2); +} + +static void testGrowingEmptyCompleteComposesWithDecodeGuard() { + // rv > 0 but an empty buffer: the loop reports a Complete empty value, and the + // bridge path's decodeGetProjExtState(apiReturn, value) still rejects it — the + // stale/empty-buffer guard survives the T2-04 rewire unchanged. + const auto r = readProjExtStateGrowing([&](char* buf, int) { + buf[0] = '\0'; + return 3; + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value.empty()); + CHECK(!decodeGetProjExtState(r.apiReturn, r.value).has_value()); +} + +static void testGrowingOverflowGivesUpAtCeiling() { + // Every attempt clips (the value never fits under 16 MB): Overflow — which is + // "unreadable WHOLE", never Absent. usage_scan folds this to the prune fail-safe + // abort, so the distinction is load-bearing. Caps run 2^16..2^24 step ×4 = 5 tries. + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + std::memset(buf, 'a', static_cast(cap - 1)); + buf[cap - 1] = '\0'; + return cap; // reports a length that never strictly fits + }); + CHECK(r.status == GrowingExtStateRead::Status::Overflow); + CHECK(calls == 5); +} + int main() { testDecodeHit(); testDecodeAbsentKey(); testDecodeNegativeReturn(); testDecodeEmptyBuffer(); + testGrowingAbsent(); + testGrowingSmallValueFitsFirstAttempt(); + testGrowingRetriesUntilStrictFit(); + testGrowingEmptyCompleteComposesWithDecodeGuard(); + testGrowingOverflowGivesUpAtCeiling(); + if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n"); return g_fail != 0; } From 0c39a716a5e584f2658c342822511c7fe188fb2c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 13:06:15 -0400 Subject: [PATCH 36/40] docs: scope deletion-authority wording to bank folder + guard growing-read NUL terminator --- src/core/instrument/map/bridge_marshal.h | 1 + src/persist.h | 4 +++- src/shell/persist/session.h | 7 +++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index d63db44..e5db3f8 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -82,6 +82,7 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { result.status = GrowingExtStateRead::Status::Absent; return result; } + buf[static_cast(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap std::string s(buf.data()); if (static_cast(s.size()) + 1 < cap) { result.status = GrowingExtStateRead::Status::Complete; diff --git a/src/persist.h b/src/persist.h index 8baa1ae..016984d 100644 --- a/src/persist.h +++ b/src/persist.h @@ -8,7 +8,9 @@ // * shell/persist/ext_state_io.h + ext_state_io.cpp — the ext-state ↔ JSON // serialization bridge, key contract, GUID minting, bank-folder relocation. // * shell/persist/prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION -// AUTHORITY (deleteOrphanFile, file-local; nothing else deletes bytes). +// AUTHORITY over USER files in the bank folder (deleteOrphanFile, file-local); +// a shell's self-cleanup of its own transient scratch files (.vstpreset temp, +// realtime finalize temp) is excluded from this authority. // // This header re-exports the split APIs so every existing caller (actions.cpp, // main.cpp, ingest.cpp, the panel TUs, capture shells) keeps compiling untouched — diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index b9c7bc7..0bec588 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -11,8 +11,11 @@ // writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID // minting and bank-folder relocation (see ext_state_io.h for the key contract). // * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan -// and THE SINGLE FILE-DELETION AUTHORITY in ReaSampler (deleteOrphanFile via -// SHFileOperationW). Nothing else in the system deletes bytes. +// and THE SINGLE FILE-DELETION AUTHORITY over USER files in the bank folder in +// ReaSampler (deleteOrphanFile via SHFileOperationW). Nothing else in the system +// deletes bank-folder bytes; a shell's self-cleanup of a transient scratch file +// it just created (the drop path's .vstpreset temp, the realtime finalize temp) +// is excluded from this authority. // // Save: serialize the BankModel JSON -> SetProjExtState under namespace // "reasampler" (ext state lives inside the .rpp, so the index travels with the From f3be4d8ccef6726808cbe1ee9915a6429b924ac3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 13:40:09 -0400 Subject: [PATCH 37/40] Q-W6: registration table (OCP) in main.cpp; bank verbs -> shell/bank_ops(Session&); persist.h + wav_trim + namespaces.h shims deleted; 61/61 capture.h realtime seam split to capture_realtime_shell.h; GetProjExtState grow-loop rehomed to core/wire/ext_state_read; stale persist.cpp/bank_panel.cpp comment refs fixed; CLAUDE.md persist/bank_book/actions bullets updated. Command-id suffixes, display phrases, and undo labels byte-identical. --- CLAUDE.md | 6 +- CMakeLists.txt | 20 +- src/app/main.cpp | 521 +++++-------------- src/core/capture/capture_paths.cpp | 2 +- src/core/capture/tail_control.h | 4 +- src/core/capture/wav_trim.h | 15 - src/core/instrument/engine/pitch_shift.h | 4 +- src/core/instrument/engine/sampler_core.h | 2 +- src/core/instrument/map/bridge_marshal.h | 63 +-- src/core/instrument/map/sample_map.cpp | 2 +- src/core/instrument/map/sample_map.h | 8 +- src/core/instrument/ui/envelope_overlay.h | 2 +- src/core/instrument/ui/waveform_view.h | 2 +- src/core/model/owned_manifest.h | 2 +- src/core/namespaces.h | 48 -- src/core/ui/bank_grid.h | 4 +- src/core/ui/card_drag.h | 2 +- src/core/ui/drag_out.h | 2 +- src/core/ui/footer_bar.h | 2 +- src/core/ui/prune_button.h | 4 +- src/core/ui/tab_strip.h | 2 +- src/core/view/mode_switch.h | 2 +- src/core/wire/ext_state_read.h | 70 +++ src/ext_keys.h | 6 +- src/ingest.cpp | 21 +- src/ingest.h | 1 - src/persist.h | 27 - src/resource.h | 2 +- src/shell/actions/action_registry.cpp | 54 +- src/shell/actions/action_registry.h | 68 ++- src/shell/actions/bank_actions.cpp | 38 +- src/shell/actions/design_view_actions.cpp | 2 +- src/shell/actions/drag_out_win.cpp | 1 - src/shell/actions/drag_out_win.h | 1 - src/shell/actions/instrument_drop_win.cpp | 7 +- src/shell/actions/instrument_drop_win.h | 1 - src/shell/actions/prune_action.cpp | 6 +- src/shell/bank_ops/bank_ops.cpp | 183 +++++++ src/shell/bank_ops/bank_ops.h | 83 +++ src/shell/capture/capture.h | 116 +---- src/shell/capture/capture_batch.cpp | 2 +- src/shell/capture/capture_orchestrator.cpp | 2 +- src/shell/capture/capture_realtime_shell.cpp | 2 +- src/shell/capture/capture_realtime_shell.h | 121 +++++ src/shell/capture/insert.cpp | 11 +- src/shell/capture/insert.h | 3 +- src/shell/capture/item_read.cpp | 1 - src/shell/capture/item_read.h | 1 - src/shell/capture/provenance_shell.cpp | 5 +- src/shell/capture/provenance_shell.h | 4 +- src/shell/capture/realtime_lifecycle.cpp | 2 +- src/shell/capture/realtime_lifecycle.h | 2 +- src/shell/capture/track_guid.cpp | 1 - src/shell/capture/track_guid.h | 1 - src/shell/instrument/editor_controls.cpp | 1 + src/shell/instrument/editor_platform.cpp | 2 +- src/shell/instrument/editor_session.cpp | 2 +- src/shell/instrument/processor_reload.cpp | 8 +- src/shell/instrument/processor_state.cpp | 1 + src/shell/instrument/reaper_bridge.cpp | 19 +- src/shell/instrument/reaper_bridge.h | 4 +- src/shell/instrument/reasampler_embed.cpp | 11 +- src/shell/instrument/reasampler_embed.h | 5 +- src/shell/instrument/reasampler_vst.h | 1 - src/shell/instrument/vst_entry.cpp | 5 +- src/shell/panel/draw_kit.cpp | 12 +- src/shell/panel/draw_kit.h | 14 +- src/shell/panel/panel_bank_ops.cpp | 234 ++------- src/shell/panel/panel_bank_ops.h | 90 +--- src/shell/panel/panel_drag.cpp | 6 +- src/shell/panel/panel_input.cpp | 5 +- src/shell/panel/panel_layout.cpp | 2 +- src/shell/panel/panel_render.cpp | 2 +- src/shell/panel/panel_state.h | 18 +- src/shell/persist/ext_state_io.cpp | 10 +- src/shell/persist/persist_internal.h | 4 +- src/shell/persist/usage_scan.cpp | 18 +- src/shell/persist/usage_scan.h | 1 - src/shell/view/view.cpp | 8 +- src/shell/view/view.h | 1 - tests/test_bridge_marshal.cpp | 7 +- 81 files changed, 970 insertions(+), 1085 deletions(-) delete mode 100644 src/core/capture/wav_trim.h delete mode 100644 src/core/namespaces.h create mode 100644 src/core/wire/ext_state_read.h delete mode 100644 src/persist.h create mode 100644 src/shell/bank_ops/bank_ops.cpp create mode 100644 src/shell/bank_ops/bank_ops.h create mode 100644 src/shell/capture/capture_realtime_shell.h diff --git a/CLAUDE.md b/CLAUDE.md index 4db0389..02c078e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel. - `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip. - `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch. -- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, index-only move/copy/remove of a sample between banks, and JSON round-trip. +- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface. - `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files. - `app_version` — REAPER-free version/channel identity: CMake-sourced semver constant, ext-state stamp value, and the full set of channel-derived identity accessors. All channel strings derive from one `REASAMPLER_CHANNEL_IS_BETA` bit; no scattered `#ifdef`s in the shells. - `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner. (`wav_trim` is now a transitional forwarding alias onto `wav_codec`, kept only so the Q-W2v TUs it feeds compile untouched; retire it once that wave lands.) @@ -88,7 +88,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. - `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** - `bank_panel` — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. -- `persist` — project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, and writing-version stamp. A `projectconfig` hook triggers a deferred session reload on undo/redo. Hosts the prune dry-run and full-set orphan queries; supplies `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core. **pS-usage:** `scanPruneOrphans` unions instance usage via `usage_scan`; `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. +- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `prune_fs` hosts the prune dry-run / full-set orphan queries (supplying `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. **pS-usage:** the prune scan unions instance usage via `usage_scan`; `PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. - `usage_scan` — extension-side prune-scan shell (pS-usage): at prune-scan time, enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and folds with `sample_usage::foldUsageRecords` / `usageHeldPaths` to produce the set of held paths — or `abortPrune` when any record is unreadable (fail-safe: an unreadable record may protect anything, so the prune halts). Feeds `prune_reconcile::mergeReferenced`. Read-only: writes no ext-state. - `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. **Never touches master or `B_MUTE`/`I_SOLO`.** - `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys. @@ -97,7 +97,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** - `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** - `draw_kit` — shared LICE draw shell: `fillSurface`, `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`, cached-font `text()`, full interaction-state model, double-buffer preserved. Consumes `theme` + `component_geometry`. -- `actions` — registers the capture/placement/slot, Design View, multi-bank, and prune action families; routes each via the `command_id`/`gaccel`/`hookcommand` contract. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`BANK_PRUNE_FOLDER`) is **the ONLY file-deletion authority in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions. +- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions. **VST3 instrument (`src/vst/`) — pure core:** - `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 304e682..6a21685 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -281,7 +281,7 @@ target_link_libraries(bank_book PRIVATE json) # itself created, so Phase R prune can tell the bank system's own orphans from # hand-dropped files. Deliberately DECOUPLED from bank_book — it tracks files # CREATED, not index membership (sample-remove is not manifest-remove). Small -# pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes + +# pure type + JSON round-trip; mirror of wav_codec / tab_strip. B-cap writes + # persists it; Phase R (R1/R2) consumes it — no prune logic here. # --------------------------------------------------------------------------- add_library(owned_manifest STATIC src/core/model/owned_manifest.cpp) @@ -335,18 +335,13 @@ target_link_libraries(capture_realtime PUBLIC bank_model) # build (formerly hand-rolled in ingest), and the WAV-aware content hashes # (formerly in capture_paths). The dedup-by-hash and null-test invariants rest # on this one implementation. File I/O stays in the shells. Depends on peaks -# for the AudioSample float alias. -# `wav_trim` remains as a TRANSITIONAL alias (forwarding header + INTERFACE -# target) so the Q-W2v-owned TUs (sample_map, VST editor/processor) build -# untouched in their parallel wave; retire both once Q-W2v lands. +# for the AudioSample float alias. (The transitional `wav_trim` alias was +# retired in Q-W6 — every includer points here directly.) # --------------------------------------------------------------------------- add_library(wav_codec STATIC src/core/capture/wav_codec.cpp) target_include_directories(wav_codec PUBLIC src) target_link_libraries(wav_codec PUBLIC peaks) -add_library(wav_trim INTERFACE) -target_link_libraries(wav_trim INTERFACE wav_codec) - # --------------------------------------------------------------------------- # 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity # core: re-exports the ONE CMake-sourced version string (via the configure_file'd @@ -563,7 +558,7 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # tested hard outside any host. Lives under core/instrument/engine/ but # links NEITHER SDK — the plain-data boundary is enforced structurally: the test # target below links only sampler_core (+ its peaks dep for the AudioSample alias, -# the one house precedent wav_trim also relies on). The VST3 shell (shell/instrument/ +# the one house precedent wav_codec also relies on). The VST3 shell (shell/instrument/ # reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. # --------------------------------------------------------------------------- # pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core). @@ -849,13 +844,13 @@ target_link_libraries(embed_strip PUBLIC editor_geometry) # instrument: the live bank blob -> selected sample (via the SHARED bank_book JSON parse, # NOT a second parser), interleaved->mono downmix (the channel policy), the Tier-0/zoned # keymap builds, and the refs/performance resolution. Links the three pure modules it -# composes — bank_book (shared JSON), wav_trim (shared WAV parse), and sampler_core (the +# composes — bank_book (shared JSON), wav_codec (shared WAV parse), and sampler_core (the # Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor) # does the bridge read + file I/O off the audio thread, then calls these; the process # callback stays allocation-free. The ComponentState codec is component_state_io below. add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp) target_include_directories(sample_map PUBLIC src) -target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) +target_link_libraries(sample_map PUBLIC bank_book wav_codec sampler_core) # component_state_io (Q-W2v split of sample_map, T4-13 ≡ T2-07) — the ComponentState # ENVELOPE + zones-payload binary codec (envelope v1..v11, zones payload v1..v7, every @@ -1107,6 +1102,7 @@ add_library(reaper_reasampler MODULE src/shell/persist/session.cpp src/shell/persist/ext_state_io.cpp src/shell/persist/prune_fs.cpp + src/shell/bank_ops/bank_ops.cpp src/shell/panel/panel_audition.cpp src/shell/panel/panel_bank_ops.cpp src/shell/panel/panel_drag.cpp @@ -1283,7 +1279,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") ) # editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure # bank->keymap mapping + state (de)ser the processor drives off the audio thread; - # linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model, + # linking it pulls its pure deps (bank_book, wav_codec, sampler_core, bank_model, # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src) # gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). diff --git a/src/app/main.cpp b/src/app/main.cpp index 85f08f4..1a5bc47 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -14,24 +14,25 @@ // storage for those global pointers. Every other .cpp includes // reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. // -// Since Q-W3 this TU is ONLY pointers + entry + dispatch: the capture -// orchestration it used to carry lives in shell/capture/ (capture_orchestrator / -// capture_batch / scope_resolve / realtime_lifecycle). The registration blocks -// below are slated for Q-W6's registration table. +// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own +// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows + +// action_registry's registerActionTable/actionTableHandleCommand/ +// unregisterActionTable) — adding a bindable action here means adding ONE row and +// its handler function, nothing else (OCP). The design_view / bank / ingest +// families keep their own register/handle/unregister triples, called from entry. #define REAPERAPI_IMPLEMENT #include "reaper_plugin.h" #include "reaper_plugin_functions.h" #include -#include #include #include #include "core/capture/render_settings.h" // captureActionTable -#include "core/version/app_version.h" // channelCommandId / channelActionName / appVersion +#include "core/version/app_version.h" // channelCommandId / appVersion #include "ingest.h" -#include "persist.h" +#include "shell/actions/action_registry.h" // the Q-W6 registration table #include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) #include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) #include "shell/capture/capture_batch.h" // batch + recapture action bodies @@ -39,75 +40,26 @@ #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver #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 #include "shell/view/view.h" // reconcileManagedLanes / applyMode namespace capture = reasampler::capture; -using reasampler::version::channelActionName; -using reasampler::version::channelCommandId; - -// Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action -// mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list -// name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the -// pure app_version module (channelCommandId / channelActionName). Stable rebuilds the exact -// shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated forever- -// family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per channel: a -// shipped suffix is as permanent as the prefix; user keybindings key off the composed id. -// -// The composed id strings are held here for the module's lifetime (idStore) so both the -// register call and the mirroring '-command_id' unregister pass the SAME stable pointer. -// A std::deque (NOT vector) is used deliberately: it never invalidates references to -// existing elements on push_back, so a c_str() handed out early stays valid after later -// interning — the unload path re-presents these same pointers. -static std::deque g_idStore; - -// Interns a composed command-id string for the module lifetime and returns its C string. -// Appended-to only during startup registration and read on unload; never cleared until -// process exit, and deque guarantees the returned pointer stays valid. -static const char* internCmdId(const std::string& suffix) { - g_idStore.push_back(channelCommandId(suffix)); - return g_idStore.back().c_str(); -} // Globals other files reference via `extern`. REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct -// ---- Capture action family (two FX scopes) --------------------------------- -// Two bindable SCOPE actions from captureActionTable() (render_settings, pure): -// capture item / track. Each infers its range (razor-else-time) and enforces the -// FX-scope invariant via FX-bypass-around-render (FxBypassGuard, now in -// capture_orchestrator): -// Item -> take/item FX only (bypass the item's track + ancestors + master). -// Track -> item FX + track's own FX (bypass ancestors + master). -// There is NO master scope — to capture the master you render a track. (The master -// track's FX/gain/pan are STILL neutralized for both scopes as the out-of-scope -// chain — master is a bypass target, not a capture scope.) The retired M7 -// CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids AND the removed -// CAPTURE_MASTER / CAPTURE_MASTER_REALTIME ids are mirror-unregistered on unload so -// old keybindings clear cleanly. -// -// The minted command ids parallel the table rows 1:1 (same index). gaccel storage -// must outlive registration (REAPER holds each pointer), so both vectors are file- -// scope and sized to the table. FOREVER-STABLE id strings live in the table. -static std::vector g_captureCmdIds; -static std::vector g_captureAccels; -// Channel-qualified capture-action labels, one per table row. REAPER holds each gaccel's -// `desc` pointer, so the composed strings live here for the module lifetime (parallel to -// g_captureAccels; never resized after the registration loop sets it). -static std::vector g_captureDescs; - -// Retired capture-action command-id SUFFIXES. Kept ONLY to mirror-unregister them on -// unload so a user's stale keybindings are cleaned up. Never re-register these. Composed -// through the channel prefix at unload (channelCommandId) so a beta unload clears beta- -// qualified retired ids and a stable unload clears stable's — each channel cleans up only -// its own family. +// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a +// user's stale keybindings are cleaned up. Never re-register these. Composed through +// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified +// retired ids and a stable unload clears stable's — each channel cleans up only its +// own family. // * The M7 four-mode ids (tracks/items/razor WET). // * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the // master realtime action are REMOVED (capture is now item + track only; realtime // taps the selected track). Their shipped ids are retired so old keybindings clear. // * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants -// are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so -// old keybindings clear. +// are REMOVED; tail is now a panel-setting toggle, not a paired action. static const char* const kRetiredCaptureCmdSuffixes[] = { "CAPTURE_TRACKS_WET", "CAPTURE_ITEMS_WET", @@ -118,60 +70,6 @@ static const char* const kRetiredCaptureCmdSuffixes[] = { "CAPTURE_TRACK_TAIL", }; -// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. -// The docked grid window is display-only this wave (Wave A) — the action just -// shows/hides it; it never captures, inserts, or mutates the bank. -static int g_cmdToggleBankPanel = 0; - -// Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that -// differ ONLY in the InsertOptions they build: the default inserts at native length -// (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's -// try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never -// silent). Both read the bank panel's current selection and place at the edit cursor. -static int g_cmdInsertSelected = 0; -static int g_cmdInsertSelectedConform = 0; - -// Command ids for the M11 batch-capture actions. NEW FOREVER-STABLE strings. One action -// fires N captures: CAPTURE_BATCH_ITEMS -> one bank sample per selected item (item scope); -// CAPTURE_BATCH_RAZOR -> one bank sample per razor area (track scope, each area's range). -// Each unit honors every precision invariant; the original selection is restored on every -// exit path. Bank-only, never places on the timeline (load-bearing principle). -static int g_cmdCaptureBatchItems = 0; -static int g_cmdCaptureBatchRazor = 0; - -// Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE -// string. Records the selected track's OWN output in realtime (transport-driven) into -// a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the -// bank. The realtime SIBLING of the offline CAPTURE_TRACK scope action: same range -// logic (razor-else-time), same track selection, same bank/persist path, different -// backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.) -static int g_cmdCaptureTrackRealtime = 0; - -// Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string -// (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED -// sample from its recorded source's current state and updates the Sample in place — -// BANK-ONLY, never places on the timeline (load-bearing principle). -static int g_cmdRecaptureFromSource = 0; - -// Command id for the S8 "capture selected item / time-selection into bank + assign" -// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline -// Item-scope capture path (RunCapture) verbatim, then writes an S8 assignment request. -// Lives in the capture family (not the ingest family) because it leans on the capture -// render machinery (capture_orchestrator). -static int g_cmdCaptureItemAssign = 0; - -// Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. -// Aborts the in-flight realtime capture (stop + restore, non-destructive). No-op -// (with a note) when nothing is in flight. -static int g_cmdCancelRealtime = 0; - -// Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand -// ONLY — prints the CMake-sourced version string to the console when fired. This is the -// SOLE new console output the versioning wave adds; there is no unconditional startup -// version print (routine console chatter was deliberately removed — it pops the console -// window). The user copies this line into a bug report. -static int g_cmdShowVersion = 0; - // The persistence session (M4): owns the in-memory BankModel and bridges it to // project ext state. A timer tick drives g_session.poll() to detect project // load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to @@ -180,6 +78,102 @@ static int g_cmdShowVersion = 0; // with the .rpp. Replaces the M3 session-only g_bank. static reasampler::ReaSamplerSession g_session; +// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so +// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup). +static int g_cmdToggleBankPanel = 0; + +// --- Action handlers (the table's function pointers) -------------------------- +// +// Each is a thin stateless routing shim: (session, per-row arg) -> the action body +// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior; +// these exist only so the table rows can be plain data with flat function pointers. + +// Capture scope family: `arg` is the captureActionTable() row index — the table rows +// below are built by iterating that pure taxonomy, so the routing stays 1:1 by +// construction (never a hand-kept parallel list). +static void RunCaptureScopeRow(int arg) { + capture::RunCapture(g_session, + capture::captureActionTable()[static_cast(arg)]); +} +static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); } +static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); } +// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md +// §insert: conform is opt-in, never silent); 0 inserts at native length. +static void RunInsertSelected(int arg) { + capture::RunInsertSelected(g_session, arg != 0); +} +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 RunShowVersion(int) { + // On-demand version readout — the ONLY version output on any path (Phase V: no + // unconditional startup print; routine console chatter pops the console window). + ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); +} + +// --- The registration table (Q-W6) -------------------------------------------- +// +// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix +// composed at register — stable rebuilds the exact shipped id, e.g. +// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the +// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its +// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister +// all iterate this data — adding an action = adding a row + a handler above. +// +// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the +// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that +// one testable list, and `arg` carries the row index back to RunCapture. The +// remaining rows are this TU's singles, in the pre-table registration order. +static std::vector buildMainActionTable() { + using reasampler::ActionTableRow; + std::vector rows; + + const auto& cap = capture::captureActionTable(); + for (std::size_t i = 0; i < cap.size(); ++i) + rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase, + &RunCaptureScopeRow, static_cast(i)}); + + // M5: show/hide the docked bank panel (display-only; never captures/inserts). + rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel}); + // S8: Item-scope capture + assignment-request write (capture family because it + // leans on the capture render machinery; the other ingest surfaces live in the + // ingest family and the panel drop callback). + rows.push_back({"CAPTURE_ITEM_ASSIGN", + "capture selected item into bank + assign to active instance", + &RunCaptureItemAssign}); + // M6: place the panel's selected sample at the edit cursor. Two variants that + // differ ONLY in InsertOptions — native length vs the explicit conform opt-in. + rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor", + &RunInsertSelected, 0}); + rows.push_back({"INSERT_SELECTED_CONFORM", + "insert selected sample at edit cursor (conform to tempo)", + &RunInsertSelected, 1}); + // M11: 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", + "batch capture selected items (one per item)", + &RunBatchCaptureItems}); + rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)", + &RunBatchCaptureRazor}); + // M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected + // track's own output into a hidden temp track, dialog-free — plus its + // cancel-in-flight companion (stop + restore, non-destructive). + rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)", + &RunCaptureRealtime}); + rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture", + &RunCancelRealtime}); + // M10: regenerate the selected PROVENANCED sample from its recorded source's + // current state, in place. Bank-only, never places on the timeline. + rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source", + &RunRecaptureFromSource}); + // Phase V: on-demand version readout for bug reports. + rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); + + return rows; +} + // The timer callback REAPER runs periodically (registered via "timer"). It only // forwards to the session poll — cheap per tick (reads the active project id and // its .rpp path, acts only on a change). @@ -275,33 +269,12 @@ static project_config_extension_t g_projectConfig{ }; // REAPER calls this for EVERY action fired anywhere; claim only our own id, -// return false otherwise so REAPER keeps looking. +// return false otherwise so REAPER keeps looking. This TU's own family dispatches +// through the registration table; the Q-W4 families claim their own ids after it. static bool OnHookCommand(int command, int /*flag*/) { if (command == 0) return false; - // Three-scope capture family: command ids parallel captureActionTable() 1:1 by index. - // Claim the fired id if it is one of ours and route to its table row. - for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i) - if (command == g_captureCmdIds[i]) - { - capture::RunCapture(g_session, capture::captureActionTable()[i]); - return true; - } - if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } - if (command == g_cmdCaptureItemAssign) { capture::RunCaptureItemAssign(g_session); return true; } - if (command == g_cmdInsertSelected) { capture::RunInsertSelected(g_session, false); return true; } - if (command == g_cmdInsertSelectedConform) { capture::RunInsertSelected(g_session, true); return true; } - if (command == g_cmdCaptureBatchItems) { capture::RunBatchCaptureItems(g_session); return true; } - if (command == g_cmdCaptureBatchRazor) { capture::RunBatchCaptureRazor(g_session); return true; } - if (command == g_cmdCaptureTrackRealtime) { capture::RunCaptureRealtimeTrack(g_session); return true; } - if (command == g_cmdCancelRealtime) { capture::RunCancelRealtime(g_session); return true; } - if (command == g_cmdRecaptureFromSource) { capture::RunRecaptureFromSource(g_session); return true; } - if (command == g_cmdShowVersion) - { - // On-demand version readout — the ONLY version output on any path. - ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); - return true; - } + if (reasampler::actionTableHandleCommand(command)) return true; // Design View action family (D4). Claims only its own ids; returns false for the // rest so this hook keeps looking (per the contract). if (reasampler::designViewHandleCommand(command)) return true; @@ -316,51 +289,11 @@ static bool OnHookCommand(int command, int /*flag*/) // Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract). static int OnToggleAction(int command) { - if (command == g_cmdToggleBankPanel) + if (command != 0 && command == g_cmdToggleBankPanel) return reasampler::bankPanelIsOpen() ? 1 : 0; return -1; // not ours / non-toggling } -// gaccel storage must outlive registration — REAPER holds the pointer. -// (The capture family's accels live in g_captureAccels, sized to the table.) -static gaccel_register_t g_accelToggleBankPanel{}; -static gaccel_register_t g_accelCaptureItemAssign{}; -static gaccel_register_t g_accelInsertSelected{}; -static gaccel_register_t g_accelInsertSelectedConform{}; -static gaccel_register_t g_accelCaptureBatchItems{}; -static gaccel_register_t g_accelCaptureBatchRazor{}; -static gaccel_register_t g_accelCaptureTrackRealtime{}; -static gaccel_register_t g_accelCancelRealtime{}; -static gaccel_register_t g_accelRecaptureFromSource{}; -static gaccel_register_t g_accelShowVersion{}; - -// gaccel desc storage. The Actions-list label is channel-qualified at runtime -// (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc` -// pointer, so each label lives here for the module lifetime. Composed once at registration. -static std::string g_descToggleBankPanel; -static std::string g_descCaptureItemAssign; -static std::string g_descInsertSelected; -static std::string g_descInsertSelectedConform; -static std::string g_descCaptureBatchItems; -static std::string g_descCaptureBatchRazor; -static std::string g_descCaptureTrackRealtime; -static std::string g_descCancelRealtime; -static std::string g_descRecaptureFromSource; -static std::string g_descShowVersion; - -// Composed command-id strings (channel-qualified), interned so register and the mirroring -// '-command_id' unregister pass the SAME pointer. Set during registration; read on unload. -static const char* g_idToggleBankPanel = nullptr; -static const char* g_idCaptureItemAssign = nullptr; -static const char* g_idInsertSelected = nullptr; -static const char* g_idInsertSelectedConform = nullptr; -static const char* g_idCaptureBatchItems = nullptr; -static const char* g_idCaptureBatchRazor = nullptr; -static const char* g_idCaptureTrackRealtime = nullptr; -static const char* g_idCancelRealtime = nullptr; -static const char* g_idRecaptureFromSource = nullptr; -static const char* g_idShowVersion = nullptr; - extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) { @@ -387,49 +320,15 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( reasampler::bankUnregisterActions(g_rec); // Tear down the S8 ingest action family — same mirror-unregister. reasampler::ingestUnregisterActions(g_rec); - // Each '-command_id' re-presents the SAME interned, channel-qualified pointer - // used at register (g_id*), so the mirror-unregister matches exactly. - g_rec->Register("-gaccel", (void*)&g_accelShowVersion); - g_rec->Register("-command_id", (void*)g_idShowVersion); - g_rec->Register("-gaccel", (void*)&g_accelRecaptureFromSource); - g_rec->Register("-command_id", (void*)g_idRecaptureFromSource); - g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); - g_rec->Register("-command_id", (void*)g_idCancelRealtime); - g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); - g_rec->Register("-command_id", (void*)g_idCaptureTrackRealtime); - g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchRazor); - g_rec->Register("-command_id", (void*)g_idCaptureBatchRazor); - g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchItems); - g_rec->Register("-command_id", (void*)g_idCaptureBatchItems); - g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); - g_rec->Register("-command_id", (void*)g_idInsertSelectedConform); - g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); - g_rec->Register("-command_id", (void*)g_idInsertSelected); - g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign); - g_rec->Register("-command_id", (void*)g_idCaptureItemAssign); - g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); - g_rec->Register("-command_id", (void*)g_idToggleBankPanel); - // Mirror-unregister the capture family: gaccel + command_id per row, with - // '-'-prefixed strings (per the contract). The command id is re-composed from - // the same suffix + channel prefix used at register — identical string. - { - const auto& table = capture::captureActionTable(); - for (std::size_t i = 0; i < table.size(); ++i) - { - if (i < g_captureAccels.size()) - g_rec->Register("-gaccel", (void*)&g_captureAccels[i]); - const std::string id = channelCommandId(table[i].commandSuffix); - g_rec->Register("-command_id", (void*)id.c_str()); - } - } - // Retire the removed M7 command ids (command_id only — we never held a gaccel + // Tear down this TU's own family from the registration table (reverse + // table order; each '-command_id' re-presents the SAME interned, + // channel-qualified pointer used at register). + reasampler::unregisterActionTable(g_rec); + // Retire the REMOVED command ids (command_id only — we never held a gaccel // for them this session). Clears stale user keybindings on unload. Composed - // per channel so a beta clears beta-qualified retired ids, stable clears its own. + // per channel so a beta clears beta-qualified retired ids, stable its own. for (const char* suffix : kRetiredCaptureCmdSuffixes) - { - const std::string id = channelCommandId(suffix); - g_rec->Register("-command_id", (void*)id.c_str()); - } + g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix)); } // Destroy the docked window and release cached thumbnails before we drop // the API pointers (DockWindowRemove/DestroyWindow need them live). @@ -450,173 +349,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_hInst = hInstance; g_rec = rec; - // Register the three-scope capture action family (command_id -> gaccel per table row). - // The single hookcommand below routes every fired id back to its row by index. - // g_captureAccels must be sized BEFORE the loop and never reallocated after — - // REAPER holds a pointer to each element until we mirror-unregister it. - { - const auto& table = capture::captureActionTable(); - g_captureCmdIds.assign(table.size(), 0); - g_captureAccels.assign(table.size(), gaccel_register_t{}); - g_captureDescs.assign(table.size(), std::string{}); - for (std::size_t i = 0; i < table.size(); ++i) - { - // Compose the channel-qualified id (prefix + suffix) and label - // ("ReaSampler[ beta]: " + phrase). The id is interned so unregister re-presents - // the same pointer; the label lives in g_captureDescs for the gaccel's lifetime. - const int cmd = - rec->Register("command_id", (void*)internCmdId(table[i].commandSuffix)); - g_captureCmdIds[i] = cmd; - if (cmd) - { - g_captureDescs[i] = channelActionName(table[i].descriptionPhrase); - g_captureAccels[i].accel.cmd = cmd; - g_captureAccels[i].desc = g_captureDescs[i].c_str(); - rec->Register("gaccel", (void*)&g_captureAccels[i]); - } - } - } - // Point the bank panel at the live session BEFORE registering its action, so // a toggle firing immediately has a session to read (M5). Does not open the // window — only stores the session pointer. reasampler::bankPanelInit(&g_session); - // Register the M5 "toggle bank panel" action (command_id -> gaccel -> - // hookcommand + toggleaction for the checked state). Id + label are channel-qualified. - g_idToggleBankPanel = internCmdId("TOGGLE_BANK_PANEL"); - g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel); + // Register this TU's whole action family from the table: command_id -> gaccel + // per row, all channel-qualified, all FOREVER-STABLE per channel. + { + const std::vector rows = buildMainActionTable(); + reasampler::registerActionTable(rec, rows.data(), rows.size()); + } + + // The panel toggle renders a checked state — resolve its minted id once and + // register the toggleaction hook that reports it. + g_cmdToggleBankPanel = reasampler::actionTableCommandId("TOGGLE_BANK_PANEL"); if (g_cmdToggleBankPanel) - { - g_descToggleBankPanel = channelActionName("toggle bank panel"); - g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; - g_accelToggleBankPanel.desc = g_descToggleBankPanel.c_str(); - rec->Register("gaccel", (void*)&g_accelToggleBankPanel); rec->Register("toggleaction", (void*)&OnToggleAction); - } - - // Register the S8 "capture selected item / time-selection into bank + assign" action - // (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and - // writes an assignment request so the active instance plays the new sample. Channel- - // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every - // capture action. Registered in the capture family (main.cpp) because it leans on the - // capture render machinery; the other two ingest surfaces live in the ingest family - // (Media-Explorer import) and the panel drop callback. - g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN"); - g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign); - if (g_cmdCaptureItemAssign) - { - g_descCaptureItemAssign = channelActionName( - "capture selected item into bank + assign to active instance"); - g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign; - g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureItemAssign); - } - - // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two - // variants: native-length (default, no stretch) and the EXPLICIT conform-to- - // tempo opt-in. Both read the bank panel selection and place at the edit cursor. - g_idInsertSelected = internCmdId("INSERT_SELECTED"); - g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected); - if (g_cmdInsertSelected) - { - g_descInsertSelected = channelActionName("insert selected sample at edit cursor"); - g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; - g_accelInsertSelected.desc = g_descInsertSelected.c_str(); - rec->Register("gaccel", (void*)&g_accelInsertSelected); - } - - g_idInsertSelectedConform = internCmdId("INSERT_SELECTED_CONFORM"); - g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform); - if (g_cmdInsertSelectedConform) - { - g_descInsertSelectedConform = channelActionName( - "insert selected sample at edit cursor (conform to tempo)"); - g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; - g_accelInsertSelectedConform.desc = g_descInsertSelectedConform.c_str(); - rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); - } - - // Register the M11 batch-capture actions (command_id -> gaccel -> hookcommand). Each - // fires N captures (one bank sample per selected item / per razor area), honoring every - // precision invariant per unit and restoring the original selection on every path. - // Channel-qualified FOREVER-STABLE ids. - g_idCaptureBatchItems = internCmdId("CAPTURE_BATCH_ITEMS"); - g_cmdCaptureBatchItems = rec->Register("command_id", (void*)g_idCaptureBatchItems); - if (g_cmdCaptureBatchItems) - { - g_descCaptureBatchItems = - channelActionName("batch capture selected items (one per item)"); - g_accelCaptureBatchItems.accel.cmd = g_cmdCaptureBatchItems; - g_accelCaptureBatchItems.desc = g_descCaptureBatchItems.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureBatchItems); - } - - g_idCaptureBatchRazor = internCmdId("CAPTURE_BATCH_RAZOR"); - g_cmdCaptureBatchRazor = rec->Register("command_id", (void*)g_idCaptureBatchRazor); - if (g_cmdCaptureBatchRazor) - { - g_descCaptureBatchRazor = - channelActionName("batch capture razor areas (one per area)"); - g_accelCaptureBatchRazor.accel.cmd = g_cmdCaptureBatchRazor; - g_accelCaptureBatchRazor.desc = g_descCaptureBatchRazor.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureBatchRazor); - } - - // Register the "capture selected track (realtime)" action (command_id -> gaccel -> - // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the - // selected track's own output in realtime into a hidden temp track, moves it into - // the bank. Dialog-free. Channel-qualified FOREVER-STABLE id. - g_idCaptureTrackRealtime = internCmdId("CAPTURE_TRACK_REALTIME"); - g_cmdCaptureTrackRealtime = rec->Register("command_id", (void*)g_idCaptureTrackRealtime); - if (g_cmdCaptureTrackRealtime) - { - g_descCaptureTrackRealtime = - channelActionName("capture selected track (realtime)"); - g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; - g_accelCaptureTrackRealtime.desc = g_descCaptureTrackRealtime.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); - } - - // Cancel-in-flight sibling: aborts a running realtime capture (stop + restore). - // Channel-qualified FOREVER-STABLE id. - g_idCancelRealtime = internCmdId("CANCEL_REALTIME_CAPTURE"); - g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime); - if (g_cmdCancelRealtime) - { - g_descCancelRealtime = channelActionName("cancel realtime capture"); - g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime; - g_accelCancelRealtime.desc = g_descCancelRealtime.c_str(); - rec->Register("gaccel", (void*)&g_accelCancelRealtime); - } - - // Register the M10 "re-capture from source" action (command_id -> gaccel -> - // hookcommand). Regenerates the selected provenanced sample from its recorded - // source's current state; bank-only, never places on the timeline. Channel- - // qualified FOREVER-STABLE id (suffix RECAPTURE_FROM_SOURCE). - g_idRecaptureFromSource = internCmdId("RECAPTURE_FROM_SOURCE"); - g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource); - if (g_cmdRecaptureFromSource) - { - g_descRecaptureFromSource = channelActionName("re-capture from source"); - g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource; - g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str(); - rec->Register("gaccel", (void*)&g_accelRecaptureFromSource); - } - - // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). - // On-demand only — prints the CMake-sourced version to the console when fired; no - // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix - // so a beta's "show version" is distinguishable from stable's in the Actions list. - g_idShowVersion = internCmdId("SHOW_VERSION"); - g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion); - if (g_cmdShowVersion) - { - g_descShowVersion = channelActionName("show version"); - g_accelShowVersion.accel.cmd = g_cmdShowVersion; - g_accelShowVersion.desc = g_descShowVersion.c_str(); - rec->Register("gaccel", (void*)&g_accelShowVersion); - } // Register the Design View action family (D4): toggle/activate mode, tag/untag/ // show-both selected tracks. Each mints its own command_id + gaccel; the single @@ -632,11 +381,11 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign // action. Shares g_session with the other families; routed by the same hookcommand via - // ingestHandleCommand. (The arrange capture+assign action is registered in the capture - // family above; the drop path is a bank_panel callback, not a bindable action.) + // ingestHandleCommand. (The arrange capture+assign action is a table row above; the + // drop path is a bank_panel callback, not a bindable action.) reasampler::ingestRegisterActions(rec, &g_session); - // One hookcommand routes every ReaSampler action (spike + toggle + Design View). + // One hookcommand routes every ReaSampler action (table + the three families). // Registered once, after all command ids are minted. rec->Register("hookcommand", (void*)&OnHookCommand); diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 705b28f..575af3e 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -110,7 +110,7 @@ std::string resolveBankFile(const std::string& projectDir, std::string projectDirOfRpp(const std::string& rppPath) { // An unsaved project reports an empty .rpp path; keep it empty so downstream - // resolution refuses (no default-location fallback). Mirrors persist.cpp's prior + // resolution refuses (no default-location fallback). Mirrors the former persist shell's // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes. if (rppPath.empty()) return {}; std::string dir = std::filesystem::path(rppPath).parent_path().string(); diff --git a/src/core/capture/tail_control.h b/src/core/capture/tail_control.h index 9430dba..fa4ae4b 100644 --- a/src/core/capture/tail_control.h +++ b/src/core/capture/tail_control.h @@ -1,6 +1,6 @@ #pragma once // tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode -// toggle. The panel shell (bank_panel.cpp) owns the SWELL window, LICE drawing, and +// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and // click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length // clamp, and the toggle's label text — lives here so it is unit-tested outside the // DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch. @@ -27,7 +27,7 @@ inline constexpr double kDefaultManualTailMs = 2000.0; inline constexpr double kManualStepMs = 250.0; // The panel's current tail setting: the mode plus the length used ONLY when the -// mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default +// mode is Manual. Held as in-memory panel/session state (shell/panel), default // None so a capture with no explicit choice stays exact-bounds / byte-identical to // today. `manualMs` is a stored default a future fine-adjust UI can tune; it is // clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest. diff --git a/src/core/capture/wav_trim.h b/src/core/capture/wav_trim.h deleted file mode 100644 index e2d182f..0000000 --- a/src/core/capture/wav_trim.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -// wav_trim — TRANSITIONAL forwarding header (Q-W3, audit §4e WAV/RIFF consolidation). -// -// The one pure owner of the WAV/RIFF byte format is now core/capture/wav_codec.{h,cpp} -// (chunk walker + layout parse + float32 build + size-field patch + content hash). -// Everything this header used to declare (WavLayout / parseWavLayout / -// extractFloatFrames / WavTruncatePlan / planWavTruncate) lives there, same -// namespace (reasampler::capture), same signatures — this include is a pure alias. -// -// Kept ONLY so the TUs a parallel wave owns (sample_map.h and the VST editor/ -// processor god-TUs, Q-W2v) compile untouched — editing them here would collide -// with that wave's in-flight split. Retire this header (and point its includers at -// wav_codec.h) once Q-W2v lands. - -#include "core/capture/wav_codec.h" diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index 2a73b8d..f9fa407 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -25,7 +25,7 @@ // #include ` unconditionally, which CANNOT enter the pure sampler_core module // (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither // SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native -// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same +// pure module alongside peaks / wav_codec, CTest-testable, RT-disciplined. Same // PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at // the SHELL, never in the pure core. // @@ -53,7 +53,7 @@ // // PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only. // Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core / -// wav_trim do the same). +// wav_codec does the same). // // RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio // thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring diff --git a/src/core/instrument/engine/sampler_core.h b/src/core/instrument/engine/sampler_core.h index 5ef1e88..e241e1a 100644 --- a/src/core/instrument/engine/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -13,7 +13,7 @@ // structurally: sampler_core_tests links neither SDK (see CMakeLists §2i). // // It shares the `AudioSample` float alias from peaks — the one house precedent for a -// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The +// pure module leaning on peaks for the audio-domain type (wav_codec does the same). The // S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the // core does no file I/O — it is handed decoded sample frames and produces audio frames. diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index e5db3f8..53a003c 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -4,7 +4,7 @@ // The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the // host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around // GetProjExtState — interpreting its int return against the buffer it filled — is pure -// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out +// and unit-tested here. Mirror of capture_paths / wav_codec splitting the arithmetic out // of a REAPER-facing shell. // // The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a @@ -36,63 +36,8 @@ namespace reasampler::instrument::map { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -// --------------------------------------------------------------------------- -// The GetProjExtState GROW-LOOP retry policy (Q-W5 rider, T2-04). -// --------------------------------------------------------------------------- -// GetProjExtState writes into a caller-supplied buffer with no documented -// query-the-size call, so a large value (bank blob, usage record) must be read by -// growing a buffer until the value fits strictly inside it. Three shells carried -// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's -// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE -// policy now lives here so the retry/termination rules cannot drift. The fiddly -// part is the termination taxonomy, which each caller folds differently: -// -// * Absent — the API returned <= 0 on some attempt: the key holds no value. -// (persist -> "" empty bank; usage_scan / bridge -> nullopt) -// * Complete — the written C string fits STRICTLY inside the buffer (size+1 < -// cap), so it cannot have been clipped: `value` is the whole value. -// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable -// WHOLE, which is NOT the same as absent. (persist warns on the -// console; usage_scan folds it to the prune fail-safe abort) -// -// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap), -// returning the API's int. A template, statically dispatched per call site — no -// virtual calls, no std::function (the §3 performance guardrail); the caller binds -// the project/namespace/key (or a resolved function pointer, VST side) in a lambda. -struct GrowingExtStateRead { - enum class Status { Absent, Complete, Overflow }; - Status status = Status::Absent; - int apiReturn = 0; // the FINAL attempt's return (<= 0 iff Absent); feeds - // decodeGetProjExtState on the bridge path unchanged - std::string value; // the whole value; meaningful only when Complete -}; - -template -GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { - // Start generous; grow ×4 if REAPER reports the value may have been clipped - // (the return is the value length; equal-to-capacity-minus-NUL is ambiguous, - // so only a strict fit terminates). Ceiling 16 MB — give up rather than loop - // forever on a pathological value. - GrowingExtStateRead result; - for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - const int rv = read(buf.data(), cap); - result.apiReturn = rv; - if (rv <= 0) { - result.status = GrowingExtStateRead::Status::Absent; - return result; - } - buf[static_cast(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) { - result.status = GrowingExtStateRead::Status::Complete; - result.value = std::move(s); - return result; - } - // else: possibly truncated -> grow and retry. - } - result.status = GrowingExtStateRead::Status::Overflow; - return result; -} +// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it +// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1 +// extension-side, so it belongs on the neutral wire seam, not the instrument map). } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index cfb35be..4f92b13 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -1,6 +1,6 @@ // sample_map — pure implementation (the RESOLUTION half; the ComponentState codec // lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / -// SWELL / vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. +// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core. #include "core/instrument/map/sample_map.h" diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index cf06733..3f6d5a8 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -3,7 +3,7 @@ // "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core // plays, and (de)serialize the instance's selected-sample choice for VST3 component // state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the -// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable +// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable // arithmetic out of a host-facing shell. // // WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam @@ -14,7 +14,7 @@ // downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic // Keymap — is pure and unit-tested here. // -// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared +// It links bank_book (the shared BankBook::deserialize) and wav_codec (the shared // 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap / // SampleData it produces). All three are pure; this stays pure. @@ -25,7 +25,7 @@ #include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse) #include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) namespace reasampler::instrument::map { @@ -167,7 +167,7 @@ struct BankChoice { }; std::vector listBanks(const std::string& banksJson); -// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields: +// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields: // [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per // frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, // documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index 0d06945..e25e647 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -46,7 +46,7 @@ // right edge and wins the tie, so the fade can be dragged open from zero). // // DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on -// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the +// sample_map / sampler_core (which would drag bank_book / wav_codec in). The shell reads the // zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here. // AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no- // hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index 171b39a..8b552cf 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -17,7 +17,7 @@ // // It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so // this header depends on editor_geometry.h rather than redefining a rectangle type. Audio -// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do +// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_codec do // the same), so the zero-crossing helper takes the same mono PCM the shell already decoded. #pragma once diff --git a/src/core/model/owned_manifest.h b/src/core/model/owned_manifest.h index 9135b75..b1bf611 100644 --- a/src/core/model/owned_manifest.h +++ b/src/core/model/owned_manifest.h @@ -3,7 +3,7 @@ // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. Unit-tested outside the DAW — the same -// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip. +// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip. // // -- What it is -------------------------------------------------------------- // diff --git a/src/core/namespaces.h b/src/core/namespaces.h deleted file mode 100644 index fd5431d..0000000 --- a/src/core/namespaces.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once -// core/namespaces.h — Q-W1 INTERIM flat-namespace shim for the not-yet-split -// god/shell TUs (bank_panel / actions / persist / ingest / main / view / capture -// shells / the VST editor+processor). The Q-W1 sub-namespaces move every clean pure -// module's symbols out of the flat `reasampler` namespace; the god modules keep their -// pre-split internals, which reference those symbols unqualified (or qualified as -// `reasampler::X`). Nominating every sub-namespace inside `reasampler` restores both -// forms ([namespace.qual]p2 routes qualified lookup through using-directives), so the -// god internals stay untouched until their own split waves. -// -// SCOPE CONTRACT: included ONLY by god/shell TUs pending their split wave -// (Q-W2/Q-W2v/Q-W3/Q-W4/Q-W5). Clean core modules must NOT include this — they -// reference cross-subsystem symbols by their real namespace homes. Each split wave -// drops this include from the TUs it rewrites; when the last split lands, delete -// this header. - -namespace reasampler { - -namespace model {} -namespace view {} -namespace capture {} -namespace audio {} -namespace ui {} -namespace reclaim {} -namespace version {} -namespace json {} -namespace util {} -namespace wire {} -namespace instrument { -namespace engine {} -namespace map {} -namespace ui {} -} // namespace instrument - -using namespace model; -using namespace view; -using namespace capture; -using namespace audio; -using namespace ui; -using namespace reclaim; -using namespace version; -using namespace util; -using namespace wire; -using namespace instrument::engine; -using namespace instrument::map; -using namespace instrument::ui; - -} // namespace reasampler diff --git a/src/core/ui/bank_grid.h b/src/core/ui/bank_grid.h index 6c4dd00..c6845a6 100644 --- a/src/core/ui/bank_grid.h +++ b/src/core/ui/bank_grid.h @@ -1,7 +1,7 @@ #pragma once #include "core/ui/rect.h" // bank_grid — the REAPER-free layout math and cache-key logic behind the docked -// bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window, +// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window, // LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What // is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and // the key that identifies a cached thumbnail — lives here so it is unit-tested @@ -80,7 +80,7 @@ std::string thumbnailKeyString(const ThumbnailKey& key); // --- Interaction (M5 Wave B): hit-test, selection, keyboard nav -------------- // // All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW, -// exactly as the layout math is. The panel shell (bank_panel.cpp) reads live mouse +// exactly as the layout math is. The panel shell (shell/panel/) reads live mouse // coordinates / key codes / modifier state via SWELL and calls into these; it owns // no selection arithmetic of its own. diff --git a/src/core/ui/card_drag.h b/src/core/ui/card_drag.h index e64ce05..1e2b379 100644 --- a/src/core/ui/card_drag.h +++ b/src/core/ui/card_drag.h @@ -2,7 +2,7 @@ // card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three // pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing // split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in -// the shell (bank_panel.cpp). Mirror of drag_out::decideGesture. +// the shell (shell/panel/panel_drag.cpp). Mirror of drag_out::decideGesture. // // 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a // strict precedence the shell evaluates on every mouse-move / at drop: diff --git a/src/core/ui/drag_out.h b/src/core/ui/drag_out.h index a24dd2c..aee7a2e 100644 --- a/src/core/ui/drag_out.h +++ b/src/core/ui/drag_out.h @@ -3,7 +3,7 @@ // drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS // drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are // unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation -// and the bank_panel gesture hook stay in the shell (drag_out_win.* + bank_panel.cpp). +// and the bank_panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp). // // 1. GESTURE BOUNDARY (invariant #4 — do not regress the internal drag). The panel // already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index 4d1e58f..fb608ca 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -3,7 +3,7 @@ // footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 // footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count // label, and the Tail button, laid out left-to-right at the footer's left. The panel shell -// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail / +// (shell/panel/) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail / // activate a mode); what is NOT DAW-bound — WHERE the toggle box, the count label, and the // Tail button sit, and which one a click lands on — lives here so it is unit-tested outside // the DAW (CLAUDE.md §load-bearing split). Mirror of action_bar / mode_switch / prune_button. diff --git a/src/core/ui/prune_button.h b/src/core/ui/prune_button.h index fbd5b3a..cf7f853 100644 --- a/src/core/ui/prune_button.h +++ b/src/core/ui/prune_button.h @@ -3,7 +3,7 @@ // prune_button — the REAPER-free layout math behind the bank_panel's Prune button // (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's // tail-footer strip that fires the "Prune bank folder" command. The panel shell -// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the Main_OnCommand +// (shell/panel/) owns the SWELL window, LICE drawing, and the Main_OnCommand // dispatch of the registered command id — all REAPER-bound, DAW-verified. What is // NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on // it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing @@ -42,7 +42,7 @@ using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h) // * buttonWidth — the button's fixed width. // * rightInset — gap from the footer's right edge to the button's right edge (the // button sits left of this inset, clearing the right-aligned version -// readout). COUPLED TO drawFooter (bank_panel.cpp): the version readout +// readout). COUPLED TO drawFooter (panel_render.cpp): the version readout // uses an 8 px right margin. The button's right edge lands at // footer.right - 84, i.e. 76 px left of the readout's right margin — // enough clearance for the ~10-char label. ALSO COUPLED to diff --git a/src/core/ui/tab_strip.h b/src/core/ui/tab_strip.h index 4f05ee9..ea11b38 100644 --- a/src/core/ui/tab_strip.h +++ b/src/core/ui/tab_strip.h @@ -8,7 +8,7 @@ // tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given // pixel width, where the overflow chevrons sit, which tab/chevron a click lands in, // and how far the strip may scroll — lives here so it is unit-tested outside the -// DAW (CLAUDE.md §load-bearing split). The panel shell (bank_panel.cpp) owns the +// DAW (CLAUDE.md §load-bearing split). The panel shell (shell/panel/) owns the // SWELL window, LICE drawing, and the live BankBook read; it calls into this seam // for every rect and every hit. Mirror of mode_switch / bank_grid. // diff --git a/src/core/view/mode_switch.h b/src/core/view/mode_switch.h index 44b8250..0b85942 100644 --- a/src/core/view/mode_switch.h +++ b/src/core/view/mode_switch.h @@ -3,7 +3,7 @@ // mode_switch — the REAPER-free layout math behind the bank_panel's Design-View // mode switch (Phase D, Wave 4 — D5). A segmented control `[ Arrange | Design ]` // (N-mode general, one segment per registered mode) drawn in a fixed-height header -// strip at the top of the docked panel. The panel shell (bank_panel.cpp) owns the +// strip at the top of the docked panel. The panel shell (shell/panel/) owns the // SWELL window, LICE drawing, and the live ViewModeModel read + mode activation — // all REAPER-bound, DAW-verified. What is NOT DAW-bound — how N segments tile a // header rectangle, and which segment a click lands in — lives here so it is diff --git a/src/core/wire/ext_state_read.h b/src/core/wire/ext_state_read.h new file mode 100644 index 0000000..0f0ec75 --- /dev/null +++ b/src/core/wire/ext_state_read.h @@ -0,0 +1,70 @@ +#pragma once +// ext_state_read — the GetProjExtState GROW-LOOP retry policy (T2-04; rehomed to +// core/wire in Q-W6 — its consumers are the extension's persist/usage-scan shells +// AND the instrument's bridge, so it lives on the neutral wire seam rather than in +// the instrument-side bridge_marshal decode helper it started in). +// +// GetProjExtState writes into a caller-supplied buffer with no documented +// query-the-size call, so a large value (bank blob, usage record) must be read by +// growing a buffer until the value fits strictly inside it. Three shells carried +// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's +// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE +// policy lives here so the retry/termination rules cannot drift. The fiddly part +// is the termination taxonomy, which each caller folds differently: +// +// * Absent — the API returned <= 0 on some attempt: the key holds no value. +// (persist -> "" empty bank; usage_scan / bridge -> nullopt) +// * Complete — the written C string fits STRICTLY inside the buffer (size+1 < +// cap), so it cannot have been clipped: `value` is the whole value. +// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable +// WHOLE, which is NOT the same as absent. (persist warns on the +// console; usage_scan folds it to the prune fail-safe abort) +// +// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap), +// returning the API's int. A template, statically dispatched per call site — no +// virtual calls, no std::function (the §3 performance guardrail); the caller binds +// the project/namespace/key (or a resolved function pointer, VST side) in a lambda. + +#include +#include +#include + +namespace reasampler::wire { + +struct GrowingExtStateRead { + enum class Status { Absent, Complete, Overflow }; + Status status = Status::Absent; + int apiReturn = 0; // the FINAL attempt's return (<= 0 iff Absent); feeds + // decodeGetProjExtState on the bridge path unchanged + std::string value; // the whole value; meaningful only when Complete +}; + +template +GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { + // Start generous; grow ×4 if REAPER reports the value may have been clipped + // (the return is the value length; equal-to-capacity-minus-NUL is ambiguous, + // so only a strict fit terminates). Ceiling 16 MB — give up rather than loop + // forever on a pathological value. + GrowingExtStateRead result; + for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = read(buf.data(), cap); + result.apiReturn = rv; + if (rv <= 0) { + result.status = GrowingExtStateRead::Status::Absent; + return result; + } + buf[static_cast(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) { + result.status = GrowingExtStateRead::Status::Complete; + result.value = std::move(s); + return result; + } + // else: possibly truncated -> grow and retry. + } + result.status = GrowingExtStateRead::Status::Overflow; + return result; +} + +} // namespace reasampler::wire diff --git a/src/ext_keys.h b/src/ext_keys.h index 71d6953..6064c4a 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -1,6 +1,6 @@ #pragma once // ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state -// namespace + key names, shared by the extension (writer, via persist.h) and the +// namespace + key names, shared by the extension (writer, via shell/persist) and the // VST3 instrument (reader, via the bridge). Both sides include this header so the // wire contract cannot drift between the two artifacts (the S4 reviewer flagged the // spike's duplicated constants as a drift risk). @@ -12,7 +12,7 @@ // either SDK. // // FOREVER-STABLE once shipped: these strings key every already-saved project's -// stored state. Changing any of them orphans that state. See persist.h for the +// stored state. Changing any of them orphans that state. See shell/persist/ext_state_io.h for the // per-key retirement / migration semantics — this header only owns the spellings. #include "core/version/app_version.h" @@ -29,7 +29,7 @@ namespace reasampler { inline const char* kProjExtNamespace() { return version::extStateNamespace().c_str(); } // The multi-bank key: the whole serialized BankBook (pool + named banks). This is -// the key the VST3 instrument reads to see the live bank (read-only, S4). persist.h +// the key the VST3 instrument reads to see the live bank (read-only, S4). ext_state_io // documents its authority + the legacy-key migration around it. inline constexpr const char* kProjExtBanksKey = "banks"; diff --git a/src/ingest.cpp b/src/ingest.cpp index 8bf93da..43e288c 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT @@ -25,7 +24,7 @@ #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) #include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) -#include "persist.h" // ReaSamplerSession +#include "shell/persist/session.h" // ReaSamplerSession #include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent @@ -47,6 +46,22 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim +// is retired; each symbol names its Q-W1 home explicitly). +using capture::BankPaths; +using capture::buildFloat32Wav; +using capture::deriveBankPaths; +using capture::hashWavContent; +using capture::parseWavLayout; +using capture::projectDirOfRpp; +using capture::WavLayout; +using util::readFileBytes; +using version::channelActionName; +using version::channelCommandId; +using wire::AssignmentRequest; +using wire::buildInstrumentDropPreset; +using wire::encodeAssignmentRequest; + namespace { // The live session the ingest paths mutate. Set once by ingestRegisterActions and read by @@ -153,7 +168,7 @@ struct ImportResult { // Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit- // float WAV, write to the project-relative bank folder, index-add, hash-dedup applied. // -// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical +// BANK CONTRACT: the instrument (wav_codec parse) expects every bank file to be a canonical // 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or // an integer-PCM or double-float WAV) would be unplayable. This function therefore: // 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path). diff --git a/src/ingest.h b/src/ingest.h index 6c74d57..a6b2203 100644 --- a/src/ingest.h +++ b/src/ingest.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // ingest — the S8 "ingest through the bank" shell (EXTENSION side). // // Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads, diff --git a/src/persist.h b/src/persist.h deleted file mode 100644 index 016984d..0000000 --- a/src/persist.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#include "core/namespaces.h" -// persist.h — COMPATIBILITY UMBRELLA (Q-W5). The former persist god-TU split into -// three TUs under shell/persist/ by responsibility: -// -// * shell/persist/session.h + session.cpp — the ReaSamplerSession class (lifecycle, -// poll identity-transition detection, the projectconfig undo/redo reload drain). -// * shell/persist/ext_state_io.h + ext_state_io.cpp — the ext-state ↔ JSON -// serialization bridge, key contract, GUID minting, bank-folder relocation. -// * shell/persist/prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION -// AUTHORITY over USER files in the bank folder (deleteOrphanFile, file-local); -// a shell's self-cleanup of its own transient scratch files (.vstpreset temp, -// realtime finalize temp) is excluded from this authority. -// -// This header re-exports the split APIs so every existing caller (actions.cpp, -// main.cpp, ingest.cpp, the panel TUs, capture shells) keeps compiling untouched — -// Q-W4 is rewriting actions.cpp in parallel, so touching callers this wave is a -// guaranteed conflict. Retiring this umbrella (callers include the split headers -// directly) is Q-W6 cleanup. -// -// core/namespaces.h stays HERE, not in the split headers/TUs: the unsplit callers -// still reference flat-namespace symbols (TailSetting, PruneReport, BankModel, ...) -// through this include, while the split persist TUs themselves reference real -// namespace homes and are shim-free. - -#include "shell/persist/ext_state_io.h" -#include "shell/persist/session.h" diff --git a/src/resource.h b/src/resource.h index 0cad811..1064b9a 100644 --- a/src/resource.h +++ b/src/resource.h @@ -6,5 +6,5 @@ // numeric ids stable and unique across the extension. // The docked bank panel (M5). A bare owner-drawn child dialog: it carries no -// controls — bank_panel.cpp paints the whole client area with LICE. +// controls — the panel shell (shell/panel/panel_render.cpp) paints the whole client area with LICE. #define IDD_BANK_PANEL 1000 diff --git a/src/shell/actions/action_registry.cpp b/src/shell/actions/action_registry.cpp index 1b7c1ec..2112111 100644 --- a/src/shell/actions/action_registry.cpp +++ b/src/shell/actions/action_registry.cpp @@ -1,9 +1,10 @@ -// action_registry.cpp — shared registration plumbing (Q-W4 split of actions.cpp). -// See action_registry.h. Needs no REAPER API pointers: rec->Register is a member -// call on the dispatch struct REAPER hands the entry point. +// action_registry.cpp — shared registration plumbing (Q-W4) + the registration +// table (Q-W6). See action_registry.h. Needs no REAPER API pointers: rec->Register +// is a member call on the dispatch struct REAPER hands the entry point. #include "shell/actions/action_registry.h" +#include #include #include @@ -23,6 +24,18 @@ using version::channelCommandId; // pointer for a given action. std::deque g_strStore; +// One registered table row: the row data plus the registry-owned registration +// artifacts (interned id, minted cmd, gaccel storage REAPER holds a pointer to). +// A std::deque so element addresses never move after push_back — REAPER keeps each +// &accel until the mirror-unregister. +struct TableEntry { + ActionTableRow row; + const char* id = nullptr; // interned channel-qualified command id + int cmd = 0; // minted command id (0 = mint failed / inert row) + gaccel_register_t accel{}; // Actions-list entry; address handed to REAPER +}; +std::deque g_table; + } // namespace const char* channelIdFor(const char* suffix) { @@ -46,4 +59,39 @@ int registerAction(reaper_plugin_info_t* rec, const char* suffix, return cmd; } +void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows, + std::size_t count) { + for (std::size_t i = 0; i < count; ++i) { + g_table.push_back(TableEntry{rows[i]}); + TableEntry& e = g_table.back(); + e.id = channelIdFor(e.row.suffix); + e.cmd = registerAction(rec, e.row.suffix, e.accel, e.row.phrase); + } +} + +bool actionTableHandleCommand(int command) { + if (command == 0) return false; + for (const TableEntry& e : g_table) + if (e.cmd != 0 && command == e.cmd) { + e.row.run(e.row.arg); + return true; + } + return false; +} + +int actionTableCommandId(const char* suffix) { + for (const TableEntry& e : g_table) + if (std::strcmp(e.row.suffix, suffix) == 0) return e.cmd; + return 0; +} + +void unregisterActionTable(reaper_plugin_info_t* rec) { + // Reverse table order, mirroring the register loop. Each '-command_id' + // re-presents the SAME interned pointer channelIdFor handed out at register. + for (auto it = g_table.rbegin(); it != g_table.rend(); ++it) { + rec->Register("-gaccel", (void*)&it->accel); + rec->Register("-command_id", (void*)it->id); + } +} + } // namespace reasampler diff --git a/src/shell/actions/action_registry.h b/src/shell/actions/action_registry.h index fd7f4ce..9f45fbe 100644 --- a/src/shell/actions/action_registry.h +++ b/src/shell/actions/action_registry.h @@ -1,13 +1,30 @@ #pragma once -// action_registry — shared registration plumbing for the bindable action families -// (Q-W4 split of actions.cpp). Owns the durable interned-string store both the -// Design View and multi-bank families register through, so a composed command id -// keeps ONE stable pointer from register to the mirror-unregister, and the -// register-a-command_id-then-gaccel sequence has one implementation. +// action_registry — shared registration plumbing + the Q-W6 registration TABLE. +// +// Two layers, one TU: +// +// * The Q-W4 plumbing (channelIdFor / registerAction): the durable interned-string +// store the action families register through, so a composed command id keeps ONE +// stable pointer from register to the mirror-unregister, and the +// register-a-command_id-then-gaccel sequence has one implementation. The +// design_view / bank / ingest families still register row-by-row through this. +// +// * The Q-W6 registration TABLE (ActionTableRow + registerActionTable / +// actionTableHandleCommand / actionTableCommandId / unregisterActionTable): the +// data-driven home of main.cpp's own action family (capture scopes, panel toggle, +// insert, batch, realtime, recapture, version). One row = one action (FOREVER- +// STABLE id suffix, display phrase, flat function-pointer handler); registration +// iterates the rows, hookcommand dispatch walks the same rows, and unload +// mirror-unregisters from them — adding an action touches the table only (OCP). +// Handlers are plain function pointers (a static dispatch walk, no std::function, +// no virtual — the §3 performance guardrail); gaccel + interned-id storage is +// owned here for the module lifetime, so REAPER's held pointers stay valid and +// the '-command_id' unregister re-presents the IDENTICAL pointer registered. // // Includes reaper_plugin.h (gaccel_register_t / reaper_plugin_info_t full defs); -// only the action-family TUs include this header. Q-W6's registration table -// subsumes this helper when the hand-written blocks become data. +// only the action-family TUs and main.cpp include this header. + +#include #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t @@ -26,4 +43,41 @@ const char* channelIdFor(const char* suffix); int registerAction(reaper_plugin_info_t* rec, const char* suffix, gaccel_register_t& accel, const char* phrase); +// --- The registration table (Q-W6) ------------------------------------------- + +// One bindable action. `suffix` and `phrase` are the channel-AGNOSTIC pieces (the +// registry composes the full id/label via channelCommandId / channelActionName); +// both must have static storage duration (string literals, or a pure static table +// like captureActionTable()). `run` fires when the minted command does; `arg` is an +// opaque per-row value passed through to it (e.g. a captureActionTable row index, or +// a bool-like flag), so sibling actions can share one handler without captures. +struct ActionTableRow { + const char* suffix; // FOREVER-STABLE command-id suffix — never change shipped + const char* phrase; // Actions-list display phrase (after the channel prefix) + void (*run)(int arg); // handler — a flat function pointer, no state + int arg = 0; // opaque per-row handler argument +}; + +// Registers every row (command_id -> gaccel, via the same interning plumbing as +// registerAction) in table order. Rows are COPIED into registry-owned storage whose +// element addresses never move (REAPER holds each gaccel pointer until unload). +// Call once at load; a failed command_id mint (cmd 0) leaves that row inert but +// still mirror-unregistered on unload (harmless, matches the pre-table behavior). +void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows, + std::size_t count); + +// Dispatches one fired command: fires the matching row's handler and returns true; +// false when the command belongs to no table row (caller's hookcommand keeps +// looking, per the claim-only contract). A flat walk over the registered rows. +bool actionTableHandleCommand(int command); + +// The minted command id for `suffix` (0 when unregistered / mint failed). For the +// callers that need a raw command id outside dispatch — e.g. the toggleaction +// checked-state hook resolving TOGGLE_BANK_PANEL once at load. +int actionTableCommandId(const char* suffix); + +// Mirror-unregisters every table row (reverse table order): '-gaccel' with the same +// held storage, '-command_id' with the SAME interned pointer used at register. +void unregisterActionTable(reaper_plugin_info_t* rec); + } // namespace reasampler diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp index 0defb26..32e854d 100644 --- a/src/shell/actions/bank_actions.cpp +++ b/src/shell/actions/bank_actions.cpp @@ -1,11 +1,12 @@ // bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of // actions.cpp). See bank_actions.h. // -// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptBankName), -// name resolution, and console feedback — over the promptless bankOp* inner verbs -// homed in panel_bank_ops (model op + persistBankOp, one bank op = one Ctrl-Z). The -// book's rules (pool privileges, collapse-by-hash, active-fallback-to-pool) all live -// in bank_book; these handlers only drive the verbs and react to the boolean. +// Q-W4 dedupe / Q-W6 seam: each mutating handler is a THIN UX SKIN — text prompts +// (promptBankName), name resolution, and console feedback — over the promptless +// bankOp* inner verbs homed in shell/bank_ops (model op + persistBankOp, one bank op +// = one Ctrl-Z), driven against this family's registered session. The book's rules +// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in +// bank_book; these handlers only drive the verbs and react to the boolean. // // REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index // return a reference INTO the book's internal vector, which a create/delete can @@ -27,9 +28,10 @@ #include "shell/actions/prune_action.h" // doBankPruneFolder — the guarded prune body #include "core/model/bank_book.h" // BankBook, nextBankId, kPoolBankId (B1) -#include "persist.h" // ReaSamplerSession (owns book()) -#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptBankName + selection seam +#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs (Q-W6 non-UI seam) +#include "shell/panel/panel_bank_ops.h" // promptBankName + the panel selection seam #include "shell/panel/panel_layout.h" // full-height toggles (B3) +#include "shell/persist/session.h" // ReaSamplerSession (owns book()) #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_ShowConsoleMsg @@ -61,9 +63,9 @@ constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; // and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; -// The live session the actions read (name resolution, member counts, prune). The -// mutations themselves run through the bankOp* verbs, which resolve the same session -// via the panel seam. Set once by bankRegisterActions; not owned here. +// The live session the actions read (name resolution, member counts, prune) and +// pass to the bankOp* verbs by reference (bankHandleCommand guards it non-null +// before any handler runs). Set once by bankRegisterActions; not owned here. ReaSamplerSession* g_session = nullptr; int g_cmdBankCreate = 0; @@ -116,7 +118,7 @@ std::string bankIdByDisplayName(const std::string& name) { void doBankCreate() { std::string name; if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; - if (bankOpCreate(name).empty()) { + if (bankOpCreate(*g_session, name).empty()) { ShowConsoleMsg( ("ReaSampler: could not create bank \"" + name + "\" (a bank with that name already exists).\n") @@ -139,7 +141,7 @@ void doBankRename() { } std::string newName; if (!promptBankName("ReaSampler: rename bank", "New name:", which, newName)) return; - if (!bankOpRename(id, newName)) { + if (!bankOpRename(*g_session, id, newName)) { // The verb rejects the pool (un-renamable) or a name already used by another // bank (unique display names, trimmed + case-insensitive). ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " @@ -184,7 +186,7 @@ void doBankDelete() { } // S9: bump only when the deleted bank held samples — dropping them changes what a live // instance referencing one could play. Deleting an EMPTY bank is purely organizational. - if (!bankOpDelete(id, /*bumpGeneration=*/members > 0)) { + if (!bankOpDelete(*g_session, id, /*bumpGeneration=*/members > 0)) { ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); } } @@ -202,7 +204,7 @@ void doBankEvacuate() { ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); return; } - if (!bankOpEvacuate(id)) { + if (!bankOpEvacuate(*g_session, id)) { ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " "destination, not a source).\n"); } @@ -218,13 +220,13 @@ void doBankActivateNext() { for (const Bank& b : g_session->book().banks()) ids.push_back(b.id); const std::string target = nextBankId(ids, g_session->book().activeBankId()); if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) - bankOpActivate(target); + bankOpActivate(*g_session, target); } // Activate the pool directly (the common "back to the default target" jump). Bindable // direct-by-id form; a general activate-bank-by-name/menu is a panel affordance. void doBankActivatePool() { - bankOpActivate(kPoolBankId); + bankOpActivate(*g_session, kPoolBankId); } // Move or copy the panel's selected samples into a named destination bank (prompted @@ -257,7 +259,7 @@ void doBankTransferSelected(bool copy) { ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); return; } - bankOpTransfer(selected, srcId, destId, copy); + bankOpTransfer(*g_session, selected, srcId, destId, copy); } // Remove the panel's selected samples from the SOURCE bank (the focused region's @@ -275,7 +277,7 @@ void doBankRemoveSelected() { ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); return; } - bankOpRemove(selected, srcId); + bankOpRemove(*g_session, selected, srcId); } } // namespace diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 11a66b8..91b76e9 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -29,7 +29,7 @@ #include "core/view/lane_keys.h" // view::isOnManualLane — the single managed/manual predicate #include "core/view/view_mode_model.h" -#include "persist.h" // ReaSamplerSession (owns view() model) +#include "shell/persist/session.h" // ReaSamplerSession (owns view() model) #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint diff --git a/src/shell/actions/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp index 3aa6e83..8316a86 100644 --- a/src/shell/actions/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // drag_out_win — OS/COM initiation of native OS drag-out (M11). See drag_out_win.h. // // Windows path (primary): a hand-rolled minimal IDataObject exposing exactly one format, diff --git a/src/shell/actions/drag_out_win.h b/src/shell/actions/drag_out_win.h index 1a5781b..04ccb6b 100644 --- a/src/shell/actions/drag_out_win.h +++ b/src/shell/actions/drag_out_win.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // drag_out_win — the OS/COM initiation half of native OS drag-out (Milestone 11). The pure // gesture-boundary decision and path-list assembly live in drag_out.*; THIS is the platform // shell that hands a resolved, existing-file path list to the operating system's drag-drop diff --git a/src/shell/actions/instrument_drop_win.cpp b/src/shell/actions/instrument_drop_win.cpp index 763d3ba..fa2503d 100644 --- a/src/shell/actions/instrument_drop_win.cpp +++ b/src/shell/actions/instrument_drop_win.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT @@ -30,6 +29,10 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using version::vstPluginName; +using wire::infoNamesFxHotspot; + namespace { // Write `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir and return its path; @@ -43,7 +46,7 @@ namespace { // // Non-throwing: every std::filesystem call uses the error_code overload. The whole body is // wrapped in try/catch to guarantee no exception crosses the REAPER C callback boundary -// (the same discipline persist.cpp uses — see its non-throwing scanPruneOrphans comment). +// (the same discipline the prune shell uses — see prune_fs.cpp's non-throwing scan comment). // // Returns the path object (not a narrow string) so the caller can: // (a) pass path.u8string() to TrackFX_SetPreset — UTF-8 on MSVC, not ACP-converted, diff --git a/src/shell/actions/instrument_drop_win.h b/src/shell/actions/instrument_drop_win.h index f9aa016..20a65cb 100644 --- a/src/shell/actions/instrument_drop_win.h +++ b/src/shell/actions/instrument_drop_win.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture // decision lives in drag_out (DragGesture::InstrumentDrop) and the pure payload construction // in instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track diff --git a/src/shell/actions/prune_action.cpp b/src/shell/actions/prune_action.cpp index 2c73851..a0b037d 100644 --- a/src/shell/actions/prune_action.cpp +++ b/src/shell/actions/prune_action.cpp @@ -9,7 +9,7 @@ #include #include -#include "persist.h" // ReaSamplerSession — pruneDryRun / pruneOrphanSet / pruneReclaim +#include "shell/persist/session.h" // ReaSamplerSession — pruneDryRun / pruneOrphanSet / pruneReclaim #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_ShowConsoleMsg @@ -31,7 +31,7 @@ namespace reasampler { // is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project // state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. void doBankPruneFolder(ReaSamplerSession& session) { - const PruneReport report = session.pruneDryRun(); + const reclaim::PruneReport report = session.pruneDryRun(); // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather @@ -85,7 +85,7 @@ void doBankPruneFolder(ReaSamplerSession& session) { } // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). - const PruneDeletionResult del = session.pruneReclaim(orphanSet); + const reclaim::PruneDeletionResult del = session.pruneReclaim(orphanSet); std::string done = "ReaSampler prune: reclaimed " + std::to_string(del.reclaimedCount) + " file(s), " + diff --git a/src/shell/bank_ops/bank_ops.cpp b/src/shell/bank_ops/bank_ops.cpp new file mode 100644 index 0000000..55dfe4e --- /dev/null +++ b/src/shell/bank_ops/bank_ops.cpp @@ -0,0 +1,183 @@ +// bank_ops.cpp — the promptless bank-verb seam (Q-W6 lift; see bank_ops.h for the +// contract). The ONE implementation home of the bank verbs (create / rename / +// delete / evacuate / activate / move / copy / remove): each mutates the given +// session's book() then persists via persistBankOp() (one bank op = one Ctrl-Z; a +// true index no-op opens NO undo point). It DOES mutate the bank BOOK — but only +// the index/model + ext-state, never the arrange, never a sample file on disk +// (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). +// REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any +// Bank*/BankModel& is invalid — verbs take ids and resolve fresh per model call. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include "shell/bank_ops/bank_ops.h" + +#include +#include + +#include "core/model/bank_book.h" // BankBook / TransferResult / RemoveScope +#include "shell/persist/session.h" // ReaSamplerSession — the session the verbs mutate + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_genGuid +#define REAPERAPI_WANT_guidToString +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model +// design: ids are caller-supplied and stable; the model stays pure and mints none). +// Distinct from a track GUID by origin only — both are canonical guidToString output. +std::string mintBankId() { + GUID g{}; + genGuid(&g); + char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) + guidToString(&g, buf); + return std::string(buf); +} + +} // namespace + +// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — +// one bank op = one Ctrl-Z. +// +// WHY THIS WRAPS AND saveToActiveProject() DOES NOT: a bank verb mutates ONLY our +// project ext-state (SetProjExtState under "reasampler"), which REAPER's undo system +// captures iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK +// documents MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h +// ~1544, ~1199). We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the +// item-move family does): a bank verb touches no tracks, FX, items, or envelopes, so +// snapshotting them would be both heavier and semantically wrong. The persist runs +// INSIDE the block so the post-mutation ext-state is the block's "after" image. +// +// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project saveToActiveProject() +// no-ops (nothing is written to ext state). We must still CLOSE the block we opened, +// but with an EMPTY label and a zero flag so REAPER DISCARDS the point instead of +// recording a no-effect undo entry — mirroring view.cpp's empty-plan close. The +// in-session model change stands and persists on the user's next save; it just earns +// no undo point until there is a project to persist into (undo of an unsaved bank op +// has nothing to roll back to anyway). The Begin/End must still be balanced, hence +// the close-either-way. (Quiet persist by design — mirrors the CAPTURE path, NOT the +// Design-View path; deliberately NO Save-As prompt.) +void persistBankOp(ReaSamplerSession& session, const char* label, + bool bumpGeneration) { + Undo_BeginBlock2(nullptr); + // S9: bump the bank-generation counter INSIDE the block, before the persist, so the + // fresh generation rides the same ext-state write (saveToActiveProject() stamps + // bankGeneration()). Bumped only for content-changing verbs (the caller decides); a + // pure-organizational verb passes false and leaves the counter be, so a + // rename/activate does not needlessly refresh live instances. + if (bumpGeneration) session.bumpBankGeneration(); + const bool persisted = session.saveToActiveProject(); + if (persisted) + Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); + else + Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point +} + +// --- Promptless inner bank verbs (one home) ------------------------------------ +// Model op + persistBankOp only; NO UX. Callers own prompts/confirms/nudges and the +// session-liveness question. Each verb persists ONLY after the model accepted — a +// rejected op opens no undo point. + +std::string bankOpCreate(ReaSamplerSession& session, const std::string& name) { + const std::string id = mintBankId(); + if (!session.book().createBank(id, name)) return {}; // duplicate display name (model rule) + persistBankOp(session, "ReaSampler: create bank"); + return id; +} + +bool bankOpRename(ReaSamplerSession& session, const std::string& bankId, + const std::string& newName) { + if (!session.book().renameBank(bankId, newName)) return false; // pool / name in use + persistBankOp(session, "ReaSampler: rename bank"); + return true; +} + +bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, + bool bumpGeneration) { + if (!session.book().deleteBank(bankId)) return false; // pool un-deletable (model rule) + persistBankOp(session, "ReaSampler: delete bank", bumpGeneration); + return true; +} + +bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId) { + if (!session.book().evacuate(bankId)) return false; // pool is a destination, not a source + // S9: evacuate moves members between banks (bank membership changes) -> bump. + persistBankOp(session, "ReaSampler: evacuate bank", /*bumpGeneration=*/true); + return true; +} + +bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId) { + if (!session.book().setActiveBank(bankId)) return false; // rejects an unknown id + persistBankOp(session, "ReaSampler: activate bank"); + return true; +} + +// NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb): +// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes +// unconditionally before the dest add collapses on hash), so the index DID +// mutate — it counts toward opening an undo point. +// * COPY collapse: the source is left intact AND the dest already held the hash, +// so NOTHING changed — a true index no-op. It must NOT open an undo point. +// Hence: copy counts only real gains; move counts gains OR collapses. Ids pass +// straight to the model op — no BankModel& cached across the loop's mutations. +bool bankOpTransfer(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy) { + BankBook& b = session.book(); + if (sampleIds.empty() || srcBankId == destBankId) return false; + if (!b.bank(srcBankId) || !b.bank(destBankId)) return false; + int ok = 0, collapsed = 0; + for (const std::string& sid : sampleIds) { + const TransferResult r = + copy ? b.copySample(sid, srcBankId, destBankId) + : b.moveSample(sid, srcBankId, destBankId); + switch (r) { + case TransferResult::Moved: + case TransferResult::Copied: ++ok; break; + case TransferResult::Collapsed: ++collapsed; break; + case TransferResult::RejectedUnknownBank: + case TransferResult::RejectedSampleAbsent: + case TransferResult::RejectedSameBank: break; + } + } + const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); + if (!mutated) return false; // nothing changed — no persist, no undo point + // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an + // instance may reference) -> bump so assigned instances refresh hands-free. + persistBankOp(session, + copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", + /*bumpGeneration=*/true); + return true; +} + +// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks +// stays latent in the model). Non-destructive to the file: a last-reference remove +// leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes +// (the manifest is untouched). Silent: recoverability is the batched undo (R-B). +bool bankOpRemove(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId) { + BankBook& b = session.book(); + if (sampleIds.empty() || !b.bank(srcBankId)) return false; + int removed = 0; + for (const std::string& sid : sampleIds) + if (b.removeSample(sid, srcBankId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + if (removed == 0) return false; // every id already absent — no undo point + // S9: a remove drops a sample from a bank (an instance referencing it must refresh — + // it will resolve to silence, per the stale-id policy) -> bump. + persistBankOp(session, "ReaSampler: remove sample(s)", /*bumpGeneration=*/true); + return true; +} + +} // namespace reasampler diff --git a/src/shell/bank_ops/bank_ops.h b/src/shell/bank_ops/bank_ops.h new file mode 100644 index 0000000..659efa0 --- /dev/null +++ b/src/shell/bank_ops/bank_ops.h @@ -0,0 +1,83 @@ +#pragma once +// bank_ops — the promptless bank-verb seam (Q-W6 lift of the Q-W4 single-owner +// verbs out of shell/panel/panel_bank_ops into a NON-UI home). Each verb is a model +// op on the given session's BankBook + persistBankOp (undo-batched ext-state +// persist) — NO prompts, NO message boxes, NO panel-state nudges, NO panel-global +// reads. The two UX surfaces consume these as thin skins: +// +// * shell/panel/panel_bank_ops — the panel's menu handlers (prompts / confirms / +// repaints), passing the panel's live session. +// * shell/actions/bank_actions — the bindable family (text prompts / console +// feedback), passing its registered session. +// +// The session arrives BY REFERENCE: there is exactly one session pointer question +// per call site (the caller's), so a missing session can never be half-reported as +// a model rejection from in here (the Q-W4 review's fail-safe-collapse concern). +// Every verb returns whether the model accepted the mutation — a rejected op +// persists nothing and opens no undo point. +// +// REAPER-facing (persist + undo blocks + GUID minting) but SDK-free in this header. + +#include +#include + +namespace reasampler { + +class ReaSamplerSession; + +// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id, +// or "" when the model rejects the name (duplicate, trimmed + case-insensitive). +// Create is purely organizational — no generation bump. +std::string bankOpCreate(ReaSamplerSession& session, const std::string& name); + +// Renames `bankId`. False when the model rejects (pool un-renamable / name in use). +bool bankOpRename(ReaSamplerSession& session, const std::string& bankId, + const std::string& newName); + +// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller +// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete +// (an evacuate-then-delete flow must still bump on the ORIGINAL membership). +bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, + bool bumpGeneration); + +// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool +// itself). Bumps the generation (membership changed). +bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId); + +// Activates `bankId` as the capture target. False on an unknown id. No bump. +bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId); + +// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to +// `destBankId` (index-only; files never relocate). Returns whether the index +// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes +// nothing (no undo point); a MOVE collapse did remove the source entry (counts). +// Persists ONE undo point ("move/copy sample(s)") only when mutated. +bool bankOpTransfer(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); + +// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes +// bytes). Returns whether anything was removed; persists one undo point when so. +bool bankOpRemove(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId); + +// Persists a completed bank-index verb as a single REAPER undo point (R-B). +// Wraps the session persist (SetProjExtState) in a Begin/End block with +// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project +// the persist no-ops and the block is closed with an empty label + zero flag (REAPER +// discards it). Callers must invoke this ONLY after a successful/effective mutation — +// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching +// here so no empty undo point is ever opened for a no-op. +// +// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a +// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave +// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / +// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so +// the stamped counter rides the same ext-state write and undo captures the pre/post +// generation with the rest of the blob. +void persistBankOp(ReaSamplerSession& session, const char* label, + bool bumpGeneration = false); + +} // namespace reasampler diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index 6fbb964..e02aa56 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -1,16 +1,16 @@ #pragma once // capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). // -// This header declares the capture *seam* the later milestones fill: -// * CaptureRequest — everything a capture needs, source-mode-agnostic. +// This header declares the SHARED capture seam (Q-W6 split of the former fat +// header — the realtime backend's async begin/tick/abort surface now lives in +// capture_realtime_shell.h): +// * CaptureRequest / CaptureResult — everything a capture needs and yields, +// source-mode-agnostic; the types BOTH backends speak. // * OfflineRenderBackend — the deterministic default; a plain CONCRETE class // (the former ICaptureBackend interface was deleted in // Q-W3, T4-26 — it had one deriver and zero polymorphic // call sites; every construction site instantiates the // concrete type). -// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven -// across timer ticks; a genuinely different lifecycle -// (see the SEAM CHOICE note at its declaration). // * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared // finished-capture metadata stamp both backends call // (Q-W3 riders T1-11 / T2-09). @@ -20,7 +20,6 @@ // REAPER-free lets callers (the capture orchestration TUs) depend on the seam // without dragging the SDK into every include site. -#include #include #include @@ -156,109 +155,4 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req, ReaProject* rateProj, ReaProject* timeSigProj, const std::string& absolutePath); -// --- Realtime-record backend: the ASYNC seam --------------------------------- -// -// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport -// on REAPER's audio thread and returns immediately — it does NOT block until the -// range completes, which takes (end - start) wall-clock seconds. Blocking the main -// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN -// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called -// from the same OnTimer that runs session.poll()) advances the in-flight record and -// reports when it is done. -// -// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The -// lifecycles are genuinely different (offline is headless + immediate — one -// synchronous capture() call returns a finished Sample; realtime is -// transport-driven + async — begin/tick/abort across timer ticks), so a shared -// interface would make offline fake a lifecycle it does not have (its tick() -// would always be Done on the first call — dead code / an LSP smell). Offline -// stays synchronous; the realtime backend owns this small bespoke async seam, -// driven by exactly one caller (the timer-driven realtime_lifecycle). This is the -// split-sync/async fork, chosen over a unified async interface for that reason. -// (The old synchronous ICaptureBackend interface over OfflineRenderBackend was -// deleted in Q-W3 — T4-26: one deriver, zero polymorphic call sites.) - -// One tick's verdict from the in-flight record. -enum class RealtimeTickStatus { - InProgress, // still recording — call tick() again next timer tick - Done, // finished (range end reached, or the user stopped) — `result` is set - Failed, // an error tore the capture down — `result.message` explains -}; - -struct RealtimeTickResult { - RealtimeTickStatus status = RealtimeTickStatus::InProgress; - CaptureResult result; // meaningful only when status == Done or Failed -}; - -// The opaque in-flight capture state. Owns the snapshot of everything to restore -// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, -// transport, edit cursor, time selection) and the record's own project handle. -// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is -// dereferenced here) by holding it behind a forward-declared type + unique_ptr. -// -// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope -// RAII guard) because the record spans ticks — no single stack frame outlives it. -// Every terminal path (normal completion, user stop, error, project switch, unload) -// funnels through the same single restore, safe to call once from whichever fires. -class RealtimeCaptureState; - -// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the -// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the -// delete is compiled in capture_realtime_shell.cpp where the type is complete, -// keeping this header REAPER-free (load-bearing split). -struct RealtimeCaptureStateDeleter { - void operator()(RealtimeCaptureState* p) const noexcept; -}; -using RealtimeCaptureHandle = - std::unique_ptr; - -// Realtime-record backend — captures by RECORDING in realtime (transport-driven) -// into a hidden temp track, then moves the recorded file into the bank as a Sample. -// For sources offline render cannot do (hardware, performed FX) and as the true -// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline -// render has none). Dialog-free: never invokes the offline-render progress window. -// -// Non-bit-identical by nature (it is realtime); offline stays the deterministic -// default. Non-destructive across EVERY terminal path — the review gate — which is -// harder here than offline because the record spans ticks: the snapshot + restore -// live on RealtimeCaptureState, not a function-scope RAII destructor. -// -// SCOPE (this increment): TRACK scope only — records the selected track's OWN -// output (item + that track's own FX + its own fader/pan, PRE-parent), matching -// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's -// output is naturally PRE-parent (the parent has not summed it yet), so the tap is -// chain-independent by construction. Item realtime is deferred (UnsupportedMode). -class RealtimeRecordBackend { -public: - // Starts a realtime record: validates the request (track scope, non-empty range, - // at least one source track, active + saved project, transport idle), snapshots - // all state to restore, creates the hidden temp track, routes a send FROM each - // source track INTO the temp track, arms, and CSurf_OnRecord — then returns - // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to - // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, - // carrying only the provenance GUIDs). On success the returned unique_ptr owns the - // in-flight state; drive it with tick(). On a validation/setup failure returns - // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was - // left mutated — begin() restores on its own failure paths). - RealtimeCaptureHandle begin(const CaptureRequest& request, - const std::vector& sourceTracks, - CaptureResult& outFailure); - - // Advances the in-flight record one tick. Reads the transport (bound to the - // record's OWN project handle so a project switch cannot confuse it), and on a - // terminal verdict stops the transport, finalizes the recorded file into the - // bank Sample (Done) or reports the failure (Failed), then restores ALL - // snapshotted state. Returns InProgress while the record is still running. - // After Done/Failed the state is spent — the caller drops the unique_ptr. - RealtimeTickResult tick(RealtimeCaptureState& state); - - // Force-terminate an in-flight record NOW without waiting for the range end: - // stops the transport, finalizes whatever was captured (best effort) or abandons - // it, and restores ALL snapshotted state. For the shutdown / project-switch - // paths (extension unload, a new project became active) where the record must - // not leak a temp track / armed track / altered transport into the user's - // project. Idempotent — safe even if a prior tick already tore the state down. - RealtimeTickResult abort(RealtimeCaptureState& state); -}; - } // namespace reasampler::capture diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index 268a20a..fc15bb5 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -19,7 +19,7 @@ #include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome #include "core/model/bank_book.h" // BankBook / Bank #include "core/model/provenance.h" // recipe parse/build, fingerprint -#include "persist.h" // ReaSamplerSession +#include "shell/persist/session.h" // ReaSamplerSession #include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline #include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid #include "shell/capture/scope_resolve.h" // ResolvedSource diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 6841ccf..486cb6e 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -17,7 +17,7 @@ #include "core/capture/tail_control.h" // TailSetting #include "core/model/provenance.h" // model::Provenance #include "ingest.h" // ingestAssignActiveInstance -#include "persist.h" // ReaSamplerSession +#include "shell/persist/session.h" // ReaSamplerSession #include "shell/capture/insert.h" // runInsert / InsertRequest #include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state diff --git a/src/shell/capture/capture_realtime_shell.cpp b/src/shell/capture/capture_realtime_shell.cpp index 981e76d..9539c61 100644 --- a/src/shell/capture/capture_realtime_shell.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -74,7 +74,7 @@ // Item realtime is deferred (UnsupportedMode): item scope would need per-item take // isolation on top of the tap, which is a separate increment. -#include "shell/capture/capture.h" +#include "shell/capture/capture_realtime_shell.h" #include #include diff --git a/src/shell/capture/capture_realtime_shell.h b/src/shell/capture/capture_realtime_shell.h new file mode 100644 index 0000000..4557b19 --- /dev/null +++ b/src/shell/capture/capture_realtime_shell.h @@ -0,0 +1,121 @@ +#pragma once +// capture_realtime_shell — the ASYNC realtime-record seam (Q-W6 split of the former +// fat capture.h: this header owns the realtime backend's begin/tick/abort surface; +// capture.h keeps the shared CaptureRequest/CaptureResult types, the offline +// backend, and the shared backend helpers). Implemented by +// capture_realtime_shell.cpp; driven by exactly one caller (realtime_lifecycle). +// +// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport +// on REAPER's audio thread and returns immediately — it does NOT block until the +// range completes, which takes (end - start) wall-clock seconds. Blocking the main +// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN +// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called +// from the same OnTimer that runs session.poll()) advances the in-flight record and +// reports when it is done. +// +// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The +// lifecycles are genuinely different (offline is headless + immediate — one +// synchronous capture() call returns a finished Sample; realtime is +// transport-driven + async — begin/tick/abort across timer ticks), so a shared +// interface would make offline fake a lifecycle it does not have (its tick() +// would always be Done on the first call — dead code / an LSP smell). Offline +// stays synchronous; the realtime backend owns this small bespoke async seam. +// This is the split-sync/async fork, chosen over a unified async interface for +// that reason. (The old synchronous ICaptureBackend interface over +// OfflineRenderBackend was deleted in Q-W3 — T4-26: one deriver, zero polymorphic +// call sites.) +// +// REAPER-free like capture.h: MediaTrack is forward-declared there and never +// dereferenced here; the REAPER-facing TU is capture_realtime_shell.cpp. + +#include +#include + +#include "shell/capture/capture.h" // CaptureRequest / CaptureResult / MediaTrack fwd + +namespace reasampler::capture { + +// One tick's verdict from the in-flight record. +enum class RealtimeTickStatus { + InProgress, // still recording — call tick() again next timer tick + Done, // finished (range end reached, or the user stopped) — `result` is set + Failed, // an error tore the capture down — `result.message` explains +}; + +struct RealtimeTickResult { + RealtimeTickStatus status = RealtimeTickStatus::InProgress; + CaptureResult result; // meaningful only when status == Done or Failed +}; + +// The opaque in-flight capture state. Owns the snapshot of everything to restore +// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, +// transport, edit cursor, time selection) and the record's own project handle. +// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is +// dereferenced here) by holding it behind a forward-declared type + unique_ptr. +// +// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope +// RAII guard) because the record spans ticks — no single stack frame outlives it. +// Every terminal path (normal completion, user stop, error, project switch, unload) +// funnels through the same single restore, safe to call once from whichever fires. +class RealtimeCaptureState; + +// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the +// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the +// delete is compiled in capture_realtime_shell.cpp where the type is complete, +// keeping this header REAPER-free (load-bearing split). +struct RealtimeCaptureStateDeleter { + void operator()(RealtimeCaptureState* p) const noexcept; +}; +using RealtimeCaptureHandle = + std::unique_ptr; + +// Realtime-record backend — captures by RECORDING in realtime (transport-driven) +// into a hidden temp track, then moves the recorded file into the bank as a Sample. +// For sources offline render cannot do (hardware, performed FX) and as the true +// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline +// render has none). Dialog-free: never invokes the offline-render progress window. +// +// Non-bit-identical by nature (it is realtime); offline stays the deterministic +// default. Non-destructive across EVERY terminal path — the review gate — which is +// harder here than offline because the record spans ticks: the snapshot + restore +// live on RealtimeCaptureState, not a function-scope RAII destructor. +// +// SCOPE (this increment): TRACK scope only — records the selected track's OWN +// output (item + that track's own FX + its own fader/pan, PRE-parent), matching +// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's +// output is naturally PRE-parent (the parent has not summed it yet), so the tap is +// chain-independent by construction. Item realtime is deferred (UnsupportedMode). +class RealtimeRecordBackend { +public: + // Starts a realtime record: validates the request (track scope, non-empty range, + // at least one source track, active + saved project, transport idle), snapshots + // all state to restore, creates the hidden temp track, routes a send FROM each + // source track INTO the temp track, arms, and CSurf_OnRecord — then returns + // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to + // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, + // carrying only the provenance GUIDs). On success the returned unique_ptr owns the + // in-flight state; drive it with tick(). On a validation/setup failure returns + // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was + // left mutated — begin() restores on its own failure paths). + RealtimeCaptureHandle begin(const CaptureRequest& request, + const std::vector& sourceTracks, + CaptureResult& outFailure); + + // Advances the in-flight record one tick. Reads the transport (bound to the + // record's OWN project handle so a project switch cannot confuse it), and on a + // terminal verdict stops the transport, finalizes the recorded file into the + // bank Sample (Done) or reports the failure (Failed), then restores ALL + // snapshotted state. Returns InProgress while the record is still running. + // After Done/Failed the state is spent — the caller drops the unique_ptr. + RealtimeTickResult tick(RealtimeCaptureState& state); + + // Force-terminate an in-flight record NOW without waiting for the range end: + // stops the transport, finalizes whatever was captured (best effort) or abandons + // it, and restores ALL snapshotted state. For the shutdown / project-switch + // paths (extension unload, a new project became active) where the record must + // not leak a temp track / armed track / altered transport into the user's + // project. Idempotent — safe even if a prior tick already tore the state down. + RealtimeTickResult abort(RealtimeCaptureState& state); +}; + +} // namespace reasampler::capture diff --git a/src/shell/capture/insert.cpp b/src/shell/capture/insert.cpp index 15e5dde..fc6e5f2 100644 --- a/src/shell/capture/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // insert.cpp — REAPER-facing placement shell (M6). See insert.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -40,7 +39,7 @@ #include "core/model/bank_model.h" #include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId #include "core/capture/capture_paths.h" -#include "persist.h" +#include "shell/persist/session.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks @@ -58,13 +57,19 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using capture::computeInsertMode; +using capture::normalizeSlashes; +using capture::resolveBankFile; +using capture::TempoConform; + namespace { namespace fs = std::filesystem; // The current project's directory (mirrors bank_panel/capture/persist). The bank // index stores relative paths; resolving a bank file needs the current .rpp dir. -// FOLLOW-UP (already noted in bank_panel.cpp): a shared "current project dir" +// FOLLOW-UP (already noted in panel_bank_ops.cpp): a shared "current project dir" // REAPER helper is a clean small refactor now that a fourth consumer exists — out // of scope for M6. std::string currentProjectDir() { diff --git a/src/shell/capture/insert.h b/src/shell/capture/insert.h index 18aa2c7..cb53934 100644 --- a/src/shell/capture/insert.h +++ b/src/shell/capture/insert.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // insert — placement of bank samples into the arrange (M6). REAPER-facing shell: // it reads the bank_panel's current selection, resolves each selected sample's // file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped @@ -28,7 +27,7 @@ class ReaSamplerSession; // tempo-conform choice) so the two action variants (native-length vs // conform-to-tempo) differ only by this struct — no divergent code paths. struct InsertRequest { - InsertOptions options; // defaults: current track, no conform, native length + capture::InsertOptions options; // defaults: current track, no conform, native length }; // The outcome of an insert action, for the caller to log to the console. diff --git a/src/shell/capture/item_read.cpp b/src/shell/capture/item_read.cpp index fb84fa6..afc640f 100644 --- a/src/shell/capture/item_read.cpp +++ b/src/shell/capture/item_read.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See // item_read.h. Compiled into the reaper_reasampler MODULE; includes // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that diff --git a/src/shell/capture/item_read.h b/src/shell/capture/item_read.h index 4c8da44..7bb74e9 100644 --- a/src/shell/capture/item_read.h +++ b/src/shell/capture/item_read.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // item_read — the ONE place a MediaItem* is read for its canonical GUID string and for // the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and // bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair diff --git a/src/shell/capture/provenance_shell.cpp b/src/shell/capture/provenance_shell.cpp index f0a1319..632f68e 100644 --- a/src/shell/capture/provenance_shell.cpp +++ b/src/shell/capture/provenance_shell.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -52,6 +51,10 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using capture::normalizeSlashes; +using capture::resolveBankFile; + std::string fxChainIdentityForTrack(MediaTrack* tr) { if (!tr) return fxChainIdentity({}); std::vector rows; diff --git a/src/shell/capture/provenance_shell.h b/src/shell/capture/provenance_shell.h index 1a1dab5..ab2cafa 100644 --- a/src/shell/capture/provenance_shell.h +++ b/src/shell/capture/provenance_shell.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place. // // The PURE provenance module (provenance.h) owns the fingerprint encoding, the @@ -29,6 +28,9 @@ namespace reasampler { class BankBook; +// Real-namespace-home using-declaration (Q-W6: the namespaces.h shim is retired). +using model::BankFileRef; + // The in-scope FX-chain identity of a source track (Track scope), folded to the // pure provenance string. Reads the track's own FX chain via TrackFX_GetCount / // TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order. diff --git a/src/shell/capture/realtime_lifecycle.cpp b/src/shell/capture/realtime_lifecycle.cpp index 275694c..44f7f2f 100644 --- a/src/shell/capture/realtime_lifecycle.cpp +++ b/src/shell/capture/realtime_lifecycle.cpp @@ -8,7 +8,7 @@ #include "shell/capture/realtime_lifecycle.h" -#include "persist.h" // ReaSamplerSession +#include "shell/persist/session.h" // ReaSamplerSession #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects diff --git a/src/shell/capture/realtime_lifecycle.h b/src/shell/capture/realtime_lifecycle.h index ac71be5..d76e58f 100644 --- a/src/shell/capture/realtime_lifecycle.h +++ b/src/shell/capture/realtime_lifecycle.h @@ -16,7 +16,7 @@ // REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). -#include "shell/capture/capture.h" // RealtimeRecordBackend / RealtimeCaptureHandle +#include "shell/capture/capture_realtime_shell.h" // RealtimeRecordBackend / RealtimeCaptureHandle namespace reasampler { class ReaSamplerSession; diff --git a/src/shell/capture/track_guid.cpp b/src/shell/capture/track_guid.cpp index 4a47d0b..2588952 100644 --- a/src/shell/capture/track_guid.cpp +++ b/src/shell/capture/track_guid.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See // track_guid.h. Compiled into the reaper_reasampler MODULE; includes // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU diff --git a/src/shell/capture/track_guid.h b/src/shell/capture/track_guid.h index f1fa3d7..64f2026 100644 --- a/src/shell/capture/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // string used as a membership-index key. Both the Design View shell (view.cpp) and // the actions layer (design_view_actions.cpp) key membership on this exact string, so the key diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 56892de..7852597 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -22,6 +22,7 @@ namespace reasampler::vst { using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters +using instrument::ui::EnvMode; // envelope_overlay's mode enum (Q-W6: shim retired) using instrument::engine::formatMasterGainLabel; using instrument::engine::masterGainLinearFromNorm; using instrument::engine::masterGainNormFromLinear; diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 956f405..3c569df 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -209,7 +209,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. - // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. + // Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp). if (self) { // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture // loss otherwise leaves the momentary-key voice hung with no note-off. diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index a7a18e2..5042ac1 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -15,7 +15,7 @@ #include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail) #include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames #include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key) #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "ext_keys.h" diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index d1fef2f..0aa5cff 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -19,7 +19,7 @@ #include #include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) #include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained) #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) @@ -31,6 +31,12 @@ namespace reasampler::vst { using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives using namespace reasampler::wire; // assignment_request + sample_usage wire records +// Q-W6 (shim retired): the shared WAV parse + file loader by their real homes. +using capture::extractFloatFrames; +using capture::parseWavLayout; +using capture::resolveBankFile; +using capture::WavLayout; +using util::readFileBytes; namespace { diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 202e82e..5f8bbe1 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -24,6 +24,7 @@ using namespace Steinberg::Vst; namespace reasampler::vst { using namespace instrument::map; // the codec + resolution vocabulary this TU marshals +using instrument::engine::masterGainMaxLinear; // FB1 taper ceiling (Q-W6: shim retired) tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { if (!state) return kResultFalse; diff --git a/src/shell/instrument/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp index fc2c3b9..92062df 100644 --- a/src/shell/instrument/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin. #include "shell/instrument/reaper_bridge.h" @@ -6,6 +5,7 @@ #include #include "core/instrument/map/bridge_marshal.h" +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04 grow-loop policy) #include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) #include "ext_keys.h" // kProjExtNamespace (shared wire contract) @@ -39,6 +39,10 @@ DEF_CLASS_IID(Steinberg::IReaperHostApplication) namespace reasampler::vst { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using capture::projectDirOfRpp; +using instrument::map::decodeGetProjExtState; + bool ReaperBridge::connect(Steinberg::FUnknown* context) { getProjExtState_ = nullptr; enumProjExtState_ = nullptr; @@ -63,7 +67,8 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { enumProjExtState_ = reinterpret_cast( reaper->getReaperApi("EnumProjExtState")); // EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call - // persist.cpp uses, so the instrument derives the project directory identically. + // the persist shell (ext_state_io.cpp) uses, so the instrument derives the project + // directory identically. enumProjects_ = reinterpret_cast( reaper->getReaperApi("EnumProjects")); // pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the @@ -93,17 +98,17 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin // GetProjExtState writes into a caller buffer; the bank blob can be large (many // samples), so grow the buffer until the value fits rather than risk a silent - // truncation. The retry policy is the SHARED pure - // instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — one loop for the + // truncation. The retry policy is the SHARED pure wire::readProjExtStateGrowing + // (T2-04 — one loop for the // extension's persist/usage reads and this bridge read; the rules cannot drift): // absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a // complete value still runs through decodeGetProjExtState (the stale/empty-buffer // guard) exactly as before. - const auto read = instrument::map::readProjExtStateGrowing( + const auto read = wire::readProjExtStateGrowing( [&](char* buf, int cap) { return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap); }); - if (read.status != instrument::map::GrowingExtStateRead::Status::Complete) + if (read.status != wire::GrowingExtStateRead::Status::Complete) return std::nullopt; // absent / empty key, or pathologically large (>16 MB) return decodeGetProjExtState(read.apiReturn, read.value); } @@ -147,7 +152,7 @@ std::string ReaperBridge::currentTrackGuid() { std::string ReaperBridge::activeProjectDir() { if (!enumProjects_) return {}; // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, - // EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure + // EMPTY for a never-saved project. Same call + convention as the persist shell; the pure // projectDirOfRpp turns the .rpp path into the project directory (parent, forward- // slashed) and keeps an unsaved project's empty path empty (no default-location // fallback — the tool's invariant). diff --git a/src/shell/instrument/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h index ef7183b..29bf057 100644 --- a/src/shell/instrument/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -16,7 +16,6 @@ // reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike. #pragma once -#include "core/namespaces.h" #include #include @@ -87,7 +86,8 @@ private: int valOut_sz); // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line // ~1264). The instrument uses idx=-1 (current tab) so it follows the active project, - // and reads the .rpp path from the out-buffer exactly as persist.cpp does. + // and reads the .rpp path from the out-buffer exactly as the persist shell + // (ext_state_io.cpp) does. using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); // SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by // writeUsageExtState (prefix-guarded) — see the read-only-bank note there. diff --git a/src/shell/instrument/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp index f5b8ddc..53e9d8d 100644 --- a/src/shell/instrument/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell. // Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports // "not supported" and draws nothing. @@ -43,6 +42,14 @@ DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface) namespace reasampler::vst { +// Real-namespace-home using-directives (Q-W6: the namespaces.h shim is retired): +// the embed strip speaks the map vocabulary (listSamples / parseBankGeneration) and +// the pure UI layout (embed_strip / editor_geometry Rect) wholesale. +using namespace reasampler::instrument::map; +using namespace reasampler::instrument::ui; +using reasampler::ui::spectralColor; +using version::vstPluginName; + namespace { #ifdef _WIN32 // Kit adapter (Phase L, L3): the embed shell's Rect (editor_geometry) -> the kit's KitBox @@ -201,7 +208,7 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { // reads as "present, no zones" — the default single-capture face lives in the editor. LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width, layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0); - const std::string label = reasampler::vstPluginName() + // channel-derived (S18) + const std::string label = version::vstPluginName() + // channel-derived (S18) (samples_.empty() ? " (bank empty)" : " (no zones)"); const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(), layout.keymap.bottom()); diff --git a/src/shell/instrument/reasampler_embed.h b/src/shell/instrument/reasampler_embed.h index 23e9b56..bcc31c1 100644 --- a/src/shell/instrument/reasampler_embed.h +++ b/src/shell/instrument/reasampler_embed.h @@ -31,7 +31,6 @@ // REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor. #pragma once -#include "core/namespaces.h" #include #include @@ -52,6 +51,10 @@ namespace reasampler::vst { class ReaSamplerProcessor; +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using instrument::map::PerformanceMap; +using instrument::map::SampleChoice; + // Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor // holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the // back-pointer to the processor is therefore always valid while this lives. diff --git a/src/shell/instrument/reasampler_vst.h b/src/shell/instrument/reasampler_vst.h index 5e99b76..3993279 100644 --- a/src/shell/instrument/reasampler_vst.h +++ b/src/shell/instrument/reasampler_vst.h @@ -18,7 +18,6 @@ // binary UID identity — the string identity lives in the pure module). #pragma once -#include "core/namespaces.h" #include "pluginterfaces/base/funknown.h" diff --git a/src/shell/instrument/vst_entry.cpp b/src/shell/instrument/vst_entry.cpp index f41070c..0ca6ed3 100644 --- a/src/shell/instrument/vst_entry.cpp +++ b/src/shell/instrument/vst_entry.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class // this module offers (the ReaSampler instrument) via the SDK's factory macros. The // Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and @@ -74,10 +73,10 @@ DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2, REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4), Steinberg::PClassInfo::kManyInstances, // cardinality kVstAudioEffectClass, // component category (fixed) - reasampler::vstPluginName().c_str(), // plug-in display name (channel-derived) + reasampler::version::vstPluginName().c_str(), // plug-in display name (channel-derived) 0, // single-component => 0 Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory - reasampler::appVersion().c_str(), // plug-in version (channel: -beta render) + reasampler::version::appVersion().c_str(), // plug-in version (channel: -beta render) kVstVersionString, // VST3 SDK version (fixed) reasampler::vst::ReaSamplerProcessor::createInstance) diff --git a/src/shell/panel/draw_kit.cpp b/src/shell/panel/draw_kit.cpp index bc7587e..c734d01 100644 --- a/src/shell/panel/draw_kit.cpp +++ b/src/shell/panel/draw_kit.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // draw_kit — the LICE/SWELL shell half of the drawing kit. See draw_kit.h. // // Compiled into the reaper_reasampler MODULE. SHELL layer: it is the only kit file that @@ -12,7 +11,7 @@ #include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure) // SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is -// provided by the host. Mirrors bank_panel.cpp's include discipline. +// provided by the host. Mirrors the panel TUs' (shell/panel/) include discipline. #ifdef _WIN32 #include #else @@ -24,6 +23,15 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using audio::ChannelEnvelope; +using audio::columnMinMax; +using audio::MinMax; +using ui::compressAmplitudeForDisplay; +using ui::roleColor; +using ui::roleColorState; +using ui::spectralColor; + // --- KitColor <-> LICE boundary ---------------------------------------------- // The one place a pure KitColor becomes a LICE_pixel. Verified packing: LICE_RGBA(r,g,b,a) diff --git a/src/shell/panel/draw_kit.h b/src/shell/panel/draw_kit.h index c8696d0..311d308 100644 --- a/src/shell/panel/draw_kit.h +++ b/src/shell/panel/draw_kit.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // draw_kit — the LICE-facing SHELL half of the shared drawing kit (Phase L, L1). This is // the ONE source of drawing for the whole system: every surface (bank_panel now; the VST // editor + embed strip at L3) fills, buttons, rows, sliders, waveforms, and — above all — @@ -41,6 +40,19 @@ class LICE_IBitmap; namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim +// is retired; the kit's pure vocabulary names its Q-W1 homes explicitly). These are +// deliberate re-exports: every draw_kit consumer speaks these types at the call +// boundary, so they surface here exactly as panel_state.h surfaces the panel's. +using audio::Envelope; +using ui::InteractionState; +using ui::KitBox; +using ui::KitButtonBox; +using ui::KitColor; +using ui::ListRowBox; +using ui::Role; +using ui::SliderGeometry; + // The kit's four cached fonts (§3.1 type scale). Consumers pass a Font to text() to pick // the size/weight; the kit maps it to the matching LICE_CachedFont. enum class Font { diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index 497c326..9b4fce0 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -1,17 +1,10 @@ -// panel_bank_ops.cpp — the bank-CRUD + menus seam of the docked bank panel (Q-W2 -// split of bank_panel.cpp; Phase B4/B5). Since Q-W4 this TU is the ONE implementation -// home of the bank verbs (create / rename / delete / evacuate / activate / move / -// copy / remove): the promptless bankOp* inner verbs (model op + persistBankOp only) -// serve BOTH thin UX skins — the panel's menu handlers here and the bindable -// bank_actions family — plus the book/bank accessors, the popup menus that drive -// them, and the selection-id / OS-drag path resolvers. -// -// Each verb mutates the session's book() then persists via persistBankOp() (one bank -// op = one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank -// BOOK — that is the whole point of B4 — but only the index/model + ext-state, never -// the arrange, never a sample file on disk (bank ops are index-only; files stay put — -// CONTEXT.md §Multi-bank). REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL -// mutation any Bank*/BankModel& is invalid — resolve fresh, pass ids. +// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel +// (Q-W2 split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the +// promptless bank verbs live in shell/bank_ops (model op + persistBankOp, taking +// ReaSamplerSession&); this TU is the panel's THIN UX SKIN over them — the menu +// handlers (prompts / confirms / message boxes / panel-state nudges / repaint), +// the book/bank accessors, the popup menus that drive them, and the selection-id / +// OS-drag path resolvers. The bindable bank_actions family is the sibling skin. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are @@ -25,24 +18,21 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_bank_ops.h" -#include "persist.h" // ReaSamplerSession — the live session the ops mutate +#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs (the Q-W6 non-UI seam) +#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetUserInputs #define REAPERAPI_WANT_ShowMessageBox #define REAPERAPI_WANT_Main_OnCommand -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::panel { namespace fs = std::filesystem; -// --- Current-project directory (mirrors persist.cpp's derivation) ------------- +// --- Current-project directory (mirrors the persist shell's derivation, ext_state_io.cpp) std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); @@ -84,19 +74,21 @@ std::vector namedBanks() { // --- Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs) ------ // -// Q-W4: each handler here owns only the panel's UX (prompts / confirms / message -// boxes / panel-state nudges / repaint); the model op + persist is the shared -// bankOp* inner verb (defined in the public section below). After a STRUCTURAL -// mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we resolve -// fresh, pass ids, and let the next refreshFingerprint repaint. On an unsaved -// project the empty-close discard in persistBankOp ensures no stale state -// survives (matches the capture/B3 quiet-persist idiom). +// Q-W4/Q-W6: each handler here owns only the panel's UX (prompts / confirms / +// message boxes / panel-state nudges / repaint); the model op + persist is the +// shared bankOp* inner verb (shell/bank_ops), which takes the live session by +// reference — the book() check answers the one session-liveness question per +// handler. After a STRUCTURAL mutation (create/delete/evacuate) any +// Bank*/BankModel& is invalid — we resolve fresh, pass ids, and let the next +// refreshFingerprint repaint. On an unsaved project the empty-close discard in +// persistBankOp ensures no stale state survives (matches the capture/B3 +// quiet-persist idiom). void doCreateBank() { if (!book()) return; std::string name; if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = bankOpCreate(name); + const std::string id = bankOpCreate(*g_panel.session, name); if (id.empty()) { ShowMessageBox("A bank with that name already exists.", "ReaSampler: create bank", 0); @@ -116,7 +108,7 @@ void doRenameBank(const std::string& bankId) { const std::string current = bk->displayName; // copy before any mutation std::string newName; if (!promptBankName("ReaSampler: rename bank", "New name:", current, newName)) return; - if (!bankOpRename(bankId, newName)) { + if (!bankOpRename(*g_panel.session, bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); return; @@ -156,7 +148,7 @@ void doDeleteBank(const std::string& bankId) { // delete path moved/dropped members) — both change what a live instance could play. An // empty-bank delete is purely organizational, no bump. The ORIGINAL member count decides // (the No-path evacuated them moments ago, but the membership still changed). - if (!bankOpDelete(bankId, /*bumpGeneration=*/members > 0)) return; + if (!bankOpDelete(*g_panel.session, bankId, /*bumpGeneration=*/members > 0)) return; // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; @@ -167,12 +159,13 @@ void doEvacuateBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; - if (!bankOpEvacuate(bankId)) return; + if (!bankOpEvacuate(*g_panel.session, bankId)) return; invalidatePanel(); } void doActivateBank(const std::string& bankId) { - if (!bankOpActivate(bankId)) return; // rejects an unknown id + if (!book()) return; // no live session — nothing to activate against + if (!bankOpActivate(*g_panel.session, bankId)) return; // rejects an unknown id invalidatePanel(); } @@ -185,7 +178,8 @@ void doActivateBank(const std::string& bankId) { void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { - if (!bankOpTransfer(sampleIds, srcBankId, destBankId, copy)) + if (!book()) return; // no live session — nothing to transfer within + if (!bankOpTransfer(*g_panel.session, sampleIds, srcBankId, destBankId, copy)) return; // nothing changed — no persist, no undo point // The selection indexed into the source; after a move those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). @@ -198,7 +192,8 @@ void transferSamples(const std::vector& sampleIds, // one-Ctrl-Z contract. Clears the stale selection and repaints on an actual removal. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { - if (!bankOpRemove(sampleIds, srcBankId)) + if (!book()) return; // no live session — nothing to remove from + if (!bankOpRemove(*g_panel.session, sampleIds, srcBankId)) return; // nothing changed — no persist, no undo point // The selection indexed into the source; after a remove those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). @@ -410,35 +405,7 @@ void showSelectionMenu(int screenX, int screenY) { namespace reasampler { -namespace { - -// Persists the book after a bank mutation. Mirrors the CAPTURE path, NOT the -// Design-View path: quiet persist — saveToActiveProject no-ops on an unsaved project -// (the change stays valid for the session and persists on the user's next save). -// Deliberately NO Save-As prompt; do not "align" with persistViewState's prompt -// idiom. Returns whether a persist actually happened, so persistBankOp can discard -// its undo block when nothing was written. Guards a null session pointer (false, -// no-op) — see persistBankOp's guard below for why this is defensive rather than -// dead code. -bool persistBook() { - if (!panel::g_panel.session) return false; // no live session: nothing to persist - return panel::g_panel.session->saveToActiveProject(); -} - -// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model -// design: ids are caller-supplied and stable; the model stays pure and mints none). -// Distinct from a track GUID by origin only — both are canonical guidToString output. -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) - guidToString(&g, buf); - return std::string(buf); -} - -} // namespace - -// One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp byte-identical twins. +// One home (Q-W4) for the former actions/panel byte-identical twins. // COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', // so the return separator is overridden to \x1f (un-typeable) via the documented // `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. @@ -457,147 +424,6 @@ bool promptBankName(const char* title, const char* caption, const std::string& i return true; } -// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — -// one bank op = one Ctrl-Z. -// -// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project -// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures -// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents -// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). -// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family -// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them -// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs -// INSIDE the block so the post-mutation ext-state is the block's "after" image. -// -// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops -// (nothing is written to ext state). We must still CLOSE the block we opened, but with -// an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a -// no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model -// change stands and persists on the user's next save; it just earns no undo point until -// there is a project to persist into (undo of an unsaved bank op has nothing to roll -// back to anyway). The Begin/End must still be balanced, hence the close-either-way. -// -// NULL-SESSION GUARD: this is a public API (panel_bank_ops.h) with callers outside -// this TU (e.g. panel_drag.cpp), not all of which are guaranteed to have re-checked -// the session pointer immediately beforehand. Bail out BEFORE Undo_BeginBlock2 — no -// block is opened, so there is nothing to balance and no risk of an unbalanced -// Begin/End pair. -void persistBankOp(const char* label, bool bumpGeneration) { - if (!panel::g_panel.session) return; // no live session: no-op, no undo point opened - Undo_BeginBlock2(nullptr); - // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the - // fresh generation rides the same ext-state write the persist makes (persistBook() -> - // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs - // (the caller decides); a pure-organizational verb passes false and leaves the counter be, - // so a rename/activate does not needlessly refresh live instances. - if (bumpGeneration) panel::g_panel.session->bumpBankGeneration(); - const bool persisted = persistBook(); - if (persisted) - Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); - else - Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point -} - -// --- Promptless inner bank verbs (Q-W4 single home) ---------------------------- -// Model op + persistBankOp only; NO UX. Callers own prompts/confirms/nudges. Each -// verb resolves the book fresh (panel::book(), null when no live session) and -// persists ONLY after the model accepted — a rejected op opens no undo point. - -std::string bankOpCreate(const std::string& name) { - BankBook* b = panel::book(); - if (!b) return {}; - const std::string id = mintBankId(); - if (!b->createBank(id, name)) return {}; // duplicate display name (model rule) - persistBankOp("ReaSampler: create bank"); - return id; -} - -bool bankOpRename(const std::string& bankId, const std::string& newName) { - BankBook* b = panel::book(); - if (!b || !b->renameBank(bankId, newName)) return false; // pool / name in use - persistBankOp("ReaSampler: rename bank"); - return true; -} - -bool bankOpDelete(const std::string& bankId, bool bumpGeneration) { - BankBook* b = panel::book(); - if (!b || !b->deleteBank(bankId)) return false; // pool un-deletable (model rule) - persistBankOp("ReaSampler: delete bank", bumpGeneration); - return true; -} - -bool bankOpEvacuate(const std::string& bankId) { - BankBook* b = panel::book(); - if (!b || !b->evacuate(bankId)) return false; // pool is a destination, not a source - // S9: evacuate moves members between banks (bank membership changes) -> bump. - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); - return true; -} - -bool bankOpActivate(const std::string& bankId) { - BankBook* b = panel::book(); - if (!b || !b->setActiveBank(bankId)) return false; // rejects an unknown id - persistBankOp("ReaSampler: activate bank"); - return true; -} - -// NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb): -// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes -// unconditionally before the dest add collapses on hash), so the index DID -// mutate — it counts toward opening an undo point. -// * COPY collapse: the source is left intact AND the dest already held the hash, -// so NOTHING changed — a true index no-op. It must NOT open an undo point. -// Hence: copy counts only real gains; move counts gains OR collapses. Ids pass -// straight to the model op — no BankModel& cached across the loop's mutations. -bool bankOpTransfer(const std::vector& sampleIds, - const std::string& srcBankId, const std::string& destBankId, - bool copy) { - BankBook* b = panel::book(); - if (!b || sampleIds.empty() || srcBankId == destBankId) return false; - if (!b->bank(srcBankId) || !b->bank(destBankId)) return false; - int ok = 0, collapsed = 0; - for (const std::string& sid : sampleIds) { - const TransferResult r = - copy ? b->copySample(sid, srcBankId, destBankId) - : b->moveSample(sid, srcBankId, destBankId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedSameBank: break; - } - } - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (!mutated) return false; // nothing changed — no persist, no undo point - // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an - // instance may reference) -> bump so assigned instances refresh hands-free. - persistBankOp(copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", - /*bumpGeneration=*/true); - return true; -} - -// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks -// stays latent in the model). Non-destructive to the file: a last-reference remove -// leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes -// (the manifest is untouched). Silent: recoverability is the batched undo (R-B). -bool bankOpRemove(const std::vector& sampleIds, - const std::string& srcBankId) { - BankBook* b = panel::book(); - if (!b || sampleIds.empty() || !b->bank(srcBankId)) return false; - int removed = 0; - for (const std::string& sid : sampleIds) - if (b->removeSample(sid, srcBankId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - if (removed == 0) return false; // every id already absent — no undo point - // S9: a remove drops a sample from a bank (an instance referencing it must refresh — - // it will resolve to silence, per the stale-id policy) -> bump. - persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); - return true; -} - // --- Selection read seam -------------------------------------------------------- std::vector bankPanelSelectedSampleIds() { diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h index 2f1c5ac..73877d0 100644 --- a/src/shell/panel/panel_bank_ops.h +++ b/src/shell/panel/panel_bank_ops.h @@ -1,97 +1,31 @@ #pragma once -// panel_bank_ops — the bank-CRUD + selection-read seam of the bank panel (Q-W2 split -// of bank_panel.h; Phase B4/B5). The .cpp is the SINGLE implementation home of the -// bank verbs (create / rename / delete / evacuate / activate / move / copy / remove): -// each promptless inner verb below drives the B1 BankBook model on the session and -// persists via persistBankOp (one bank op = one Ctrl-Z). Q-W4 dedupe: the panel's -// menu handlers and the bank_actions bindable family are both thin UX skins -// (prompts / confirms / console vs. message boxes / panel-state nudges) over these -// one-home verbs. This header carries that verb surface, the shared prompt/persist -// helpers, and the panel's public selection-read surface. +// panel_bank_ops — the bank-CRUD-UX + selection-read seam of the bank panel (Q-W2 +// split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the +// promptless bank verbs themselves live in the NON-UI shell/bank_ops seam +// (bankOp* + persistBankOp, taking ReaSamplerSession&); this TU is the panel's +// thin UX skin over them — prompts / confirms / message boxes / panel-state +// nudges / repaints — plus the popup menus that drive them. The bank_actions +// bindable family is the sibling skin over the same verbs. This header carries +// the shared prompt helper and the panel's public selection-read surface. // -// The selection reads are REAPER-free; the verbs and helpers are REAPER-facing -// (persist + stock dialogs) but SDK-free in this header. +// The selection reads are REAPER-free; the prompt helper is REAPER-facing (stock +// dialogs) but SDK-free in this header. #include #include namespace reasampler { -// --- Promptless inner bank verbs (Q-W4 single home) -------------------------- -// Each verb: model op on the session's BankBook + persistBankOp (undo-batched -// ext-state persist) — NO prompts, NO message boxes, NO panel-state nudges. The -// caller owns all UX. Every verb returns whether the model accepted the mutation -// (a rejected op persists nothing and opens no undo point). Verbs resolve the -// session via the panel's live session pointer (set at load by bankPanelInit, -// before any action can fire) and fail safe (false / "") when it is absent. - -// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id, -// or "" when the model rejects the name (duplicate, trimmed + case-insensitive). -// Create is purely organizational — no generation bump. -std::string bankOpCreate(const std::string& name); - -// Renames `bankId`. False when the model rejects (pool un-renamable / name in use). -bool bankOpRename(const std::string& bankId, const std::string& newName); - -// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller -// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete -// (an evacuate-then-delete flow must still bump on the ORIGINAL membership). -bool bankOpDelete(const std::string& bankId, bool bumpGeneration); - -// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool -// itself). Bumps the generation (membership changed). -bool bankOpEvacuate(const std::string& bankId); - -// Activates `bankId` as the capture target. False on an unknown id. No bump. -bool bankOpActivate(const std::string& bankId); - -// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to -// `destBankId` (index-only; files never relocate). Returns whether the index -// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes -// nothing (no undo point); a MOVE collapse did remove the source entry (counts). -// Persists ONE undo point ("move/copy sample(s)") only when mutated. -bool bankOpTransfer(const std::vector& sampleIds, - const std::string& srcBankId, const std::string& destBankId, - bool copy); - -// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes -// bytes). Returns whether anything was removed; persists one undo point when so. -bool bankOpRemove(const std::vector& sampleIds, - const std::string& srcBankId); - -// --- Shared UX/persist helpers ------------------------------------------------ - // Prompts the user for a single line of text via REAPER's stock input dialog // (GetUserInputs). `initial` pre-fills the field. Returns false (leaving `out` // untouched) on cancel or an empty entry. COMMA GUARD: the return separator is // overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, // so any printable name — commas included — round-trips whole (SDK ~3806/3808). -// One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp twins. +// One home (Q-W4) for the former actions/panel byte-identical twins; shared by the +// panel menus and the bank_actions bindable family. bool promptBankName(const char* title, const char* caption, const std::string& initial, std::string& out); -// Persists a completed bank-index verb as a single REAPER undo point (R-B). -// Wraps the session persist (SetProjExtState) in a Begin/End block with -// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project -// the persist no-ops and the block is closed with an empty label + zero flag (REAPER -// discards it). Callers must invoke this ONLY after a successful/effective mutation — -// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching -// here so no empty undo point is ever opened for a no-op. -// -// NULL-SESSION GUARD: this is a public API with callers outside panel_bank_ops.cpp -// (e.g. panel_drag.cpp). If the panel's session pointer is absent (no live session), -// this is a no-op — no undo block is opened. Today every real caller only reaches -// here via a prior session-backed check, so the guard is not yet reachable in -// practice; it exists to make the function safe to call standalone. -// -// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a -// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave -// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / -// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so -// the stamped counter rides the same ext-state write and undo captures the pre/post -// generation with the rest of the blob. -void persistBankOp(const char* label, bool bumpGeneration = false); - // The stable ids of the currently-selected samples, in bank (insertion) order. // Empty when nothing is selected or the panel has never opened. This is the clean // seam the `insert` action reads to know WHAT to place — it returns ids (not grid diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index f2999b2..8edda00 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -20,7 +20,7 @@ #include "shell/panel/panel_state.h" -#include "shell/panel/panel_bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B) +#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B) #include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) #include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) @@ -374,7 +374,7 @@ namespace { void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point - persistBankOp("ReaSampler: reorder sample"); + persistBankOp(*g_panel.session, "ReaSampler: reorder sample"); g_panel.selection = Selection{}; invalidatePanel(); } @@ -387,7 +387,7 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId, const std::string& bankId) { if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP - persistBankOp("ReaSampler: replace sample"); + persistBankOp(*g_panel.session, "ReaSampler: replace sample"); g_panel.selection = Selection{}; invalidatePanel(); } diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 84243fb..7edade5 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -18,7 +18,7 @@ #include "shell/panel/panel_input.h" #include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) -#include "persist.h" // ReaSamplerSession — view/tail reads + mutation +#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) @@ -157,7 +157,8 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, // // INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a // background metadata update (like setting a label), not a destructive project edit. -// persist.cpp writes it on the next project save alongside the bank and view state, the +// the persist shell (ext_state_io.cpp) writes it on the next project save alongside the +// bank and view state, the // same way an action-driven tag is persisted. Wrapping this in an Undo block would flood // the REAPER undo history with a new entry for every timer tick that sees new content. // Returns true iff this tick tagged at least one new GUID into a mode — the signal the diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index b1a2e30..0a1eceb 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -20,7 +20,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_layout.h" -#include "persist.h" // ReaSamplerSession — mode/view reads +#include "shell/persist/session.h" // ReaSamplerSession — mode/view reads #include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId() // Action-trigger buttons (M11): resolve each button's command id at runtime from the diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 04e44d6..8e19795 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -18,7 +18,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1) -#include "persist.h" // ReaSamplerSession — mode/view/tail reads +#include "shell/persist/session.h" // ReaSamplerSession — mode/view/tail reads #include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model namespace reasampler::panel { diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 16fdefc..1cc153c 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -14,13 +14,9 @@ // plain free function — direct call-through, no interface, no virtual dispatch // (T4-28: the audition path and the per-mouse-move path must stay direct calls). // * Explicit using-declarations pulling the pure modules' symbols into -// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — this -// header itself does not directly include the interim core/namespaces.h shim -// (Q-W2 retires that direct dependency for this module; Q-W4 retired the -// actions.h carrier with the actions split). Several panel TUs still pull the -// shim in TRANSITIVELY via persist.h/ingest.h/draw_kit.h/view.h; only -// panel_thumbnails.cpp and panel_audition.cpp are shim-free end to end. -// Nothing HERE depends on it either way. +// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces). The +// interim core/namespaces.h shim is GONE (deleted in Q-W6 with the last split); +// every symbol below names its true home. // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, @@ -84,10 +80,10 @@ namespace reasampler::panel { // --- Real-namespace-home using-declarations ----------------------------------- // // The panel's pre-split internals reference the pure modules' symbols unqualified; -// these explicit per-symbol usings (NOT the core/namespaces.h shim) keep those -// references valid while documenting each symbol's Q-W1 home. Flat-`reasampler` -// symbols (BankBook / ViewModeModel / the draw_kit shell / persistBankOp / ...) -// resolve via the enclosing namespace and need no using. +// these explicit per-symbol usings keep those references valid while documenting +// each symbol's Q-W1 home. Flat-`reasampler` symbols (BankBook / ViewModeModel / +// the draw_kit shell / the shell/bank_ops verbs / ...) resolve via the enclosing +// namespace and need no using. // core/ui using ui::ActionBarRect; diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index f5c7e03..3f7d92d 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -36,7 +36,7 @@ #include "core/capture/capture_paths.h" // projectDirOfRpp (pure path arithmetic) #include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) -#include "core/instrument/map/bridge_marshal.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) #include "core/version/app_version.h" #define REAPERAPI_MINIMAL @@ -77,15 +77,15 @@ std::string projectDirOf(const std::string& rppPath) { // GetProjExtState needs a caller-supplied buffer; the index JSON can be large // (many samples). The grow-until-strict-fit retry policy is the SHARED pure -// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — the same policy the -// usage_scan and VST-bridge reads run); this wrapper binds the REAPER call and +// wire::readProjExtStateGrowing (T2-04 — the same policy the usage_scan and +// VST-bridge reads run); this wrapper binds the REAPER call and // folds the terminal cases persist's callers expect: "" for an absent key (a valid // empty bank, not an error) and a console warning + "" for a value exceeding the // 16 MB ceiling, so an over-large value reads as "too large to load", not silent // data loss (mirrors the malformed-JSON warning in loadFromProject). std::string getProjExtStateString(void* proj, const char* ns, const char* key) { - using instrument::map::GrowingExtStateRead; - const GrowingExtStateRead read = instrument::map::readProjExtStateGrowing( + using wire::GrowingExtStateRead; + const GrowingExtStateRead read = wire::readProjExtStateGrowing( [&](char* buf, int cap) { return GetProjExtState(static_cast(proj), ns, key, buf, cap); }); diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h index 7d41a0f..f574387 100644 --- a/src/shell/persist/persist_internal.h +++ b/src/shell/persist/persist_internal.h @@ -29,8 +29,8 @@ std::string projectDirOf(const std::string& rppPath); // Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns // "" when the key is absent (a valid empty bank, not an error) and warns on the // console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored). -// The retry policy itself is the shared pure instrument::map::readProjExtStateGrowing -// (Q-W5 rider, T2-04); this wrapper binds the REAPER call + persist's fold. +// The retry policy itself is the shared pure wire::readProjExtStateGrowing +// (T2-04; rehomed to core/wire in Q-W6); this wrapper binds the REAPER call + persist's fold. std::string getProjExtStateString(void* proj, const char* ns, const char* key); // The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. diff --git a/src/shell/persist/usage_scan.cpp b/src/shell/persist/usage_scan.cpp index 82adb98..2c8b261 100644 --- a/src/shell/persist/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune // protection; every decision is in the pure sample_usage module, this TU only reads. // @@ -27,7 +26,7 @@ #include #include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles) -#include "core/instrument/map/bridge_marshal.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) #include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix #include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex #include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) @@ -53,6 +52,14 @@ namespace reasampler { +// Real-namespace-home using-directive (Q-W6: the namespaces.h shim is retired): +// this TU speaks the sample_usage wire vocabulary wholesale (UsageRecord / +// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus the +// channel-identity accessors + the preset class-id hex. +using namespace reasampler::wire; +using version::vstOutputName; +using version::vstPluginName; + namespace { // The three UPPERCASED channel needles identityMatches (pure, sample_usage) checks @@ -169,16 +176,15 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { // Growing GetProjExtState read: the usage record scales with the hold count, so a // fixed buffer risks a truncated decode. The retry policy is the SHARED pure -// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — one loop for persist, -// this prune-safety-adjacent read, and the VST bridge; the rules cannot drift). +// wire::readProjExtStateGrowing (T2-04 — one loop for persist, this +// prune-safety-adjacent read, and the VST bridge; the rules cannot drift). // Returns nullopt when the key cannot be read WHOLE — absent-after-enumeration // (rv <= 0) or pathologically large (> 16 MB give-up). The caller only queries keys // the enumeration just listed, so a nullopt here is a PRESENT-BUT-UNREADABLE record: // it folds to abortPrune (fail-safe — silently reduced protection is the delete // direction). std::optional readExtStateValue(ReaProject* proj, const char* key) { - using instrument::map::GrowingExtStateRead; - const GrowingExtStateRead read = instrument::map::readProjExtStateGrowing( + const GrowingExtStateRead read = readProjExtStateGrowing( [&](char* buf, int cap) { return GetProjExtState(proj, kProjExtNamespace(), key, buf, cap); }); diff --git a/src/shell/persist/usage_scan.h b/src/shell/persist/usage_scan.h index c1bfe0b..e9fd5d8 100644 --- a/src/shell/persist/usage_scan.h +++ b/src/shell/persist/usage_scan.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for // the pure core, the fail-safe folds, and the full design note). At prune-scan time it // answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 3c6f5fd..a427f05 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // view.cpp — REAPER-facing Design View shell (Phase D2). See view.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h @@ -48,6 +47,13 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using view::buildFolderTree; +using view::isOnManualLane; +using view::managedLaneKey; +using view::modeIdFromLaneName; +using view::TrackFolderEntry; + namespace { // Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 4ad8c58..9260cd7 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,5 +1,4 @@ #pragma once -#include "core/namespaces.h" // view — the REAPER-facing shell of the Design View feature (Phase D2). It is the // mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/ // membership/snapshot state and emits the toggle plan; this shell reads the live diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index 04730cd..e4d7b65 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -6,13 +6,15 @@ // Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer // guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4 // (the instrument now parses the bank through the shared bank_book JSON path), so its -// cases are gone with it. Q-W5 (rider T2-04) adds readProjExtStateGrowing — the ONE -// grow-loop retry policy shared by persist / usage_scan / reaper_bridge — covered +// cases are gone with it. Q-W5 (rider T2-04) added readProjExtStateGrowing — the ONE +// grow-loop retry policy shared by persist / usage_scan / reaper_bridge, rehomed to +// core/wire/ext_state_read.h in Q-W6 and still pinned here alongside the decode — covered // against a fake read: absent, small-fit, grow-then-fit, empty-complete (composed with // the decode guard), and the 16 MB overflow give-up. The overflow case is // prune-safety-adjacent (usage_scan folds it to abortPrune), so it is pinned here. #include "../src/core/instrument/map/bridge_marshal.h" +#include "../src/core/wire/ext_state_read.h" // readProjExtStateGrowing (Q-W6 rehome) #include #include @@ -20,6 +22,7 @@ using namespace reasampler; using namespace reasampler::instrument::map; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ From cd12b97631e229fb8997c4413f8854557882d6fb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 13:51:35 -0400 Subject: [PATCH 38/40] Q-W6 review follow-ups: drop stale wav_trim clause, fix stale include comment, idempotent action-table clear, reflow ragged comment, align session.h trailing comments --- CLAUDE.md | 2 +- src/app/main.cpp | 2 +- src/shell/actions/action_registry.cpp | 1 + src/shell/actions/design_view_actions.cpp | 2 +- src/shell/capture/capture_batch.cpp | 2 +- src/shell/capture/capture_orchestrator.cpp | 2 +- src/shell/panel/panel_input.cpp | 12 ++++++------ src/shell/panel/panel_layout.cpp | 2 +- src/shell/panel/panel_render.cpp | 2 +- 9 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02c078e..61cc31c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface. - `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files. - `app_version` — REAPER-free version/channel identity: CMake-sourced semver constant, ext-state stamp value, and the full set of channel-derived identity accessors. All channel strings derive from one `REASAMPLER_CHANNEL_IS_BETA` bit; no scattered `#ifdef`s in the shells. -- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner. (`wav_trim` is now a transitional forwarding alias onto `wav_codec`, kept only so the Q-W2v TUs it feeds compile untouched; retire it once that wave lands.) +- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner). - `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** - `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them. - `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button. diff --git a/src/app/main.cpp b/src/app/main.cpp index 1a5bc47..a721162 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -30,7 +30,7 @@ #include #include "core/capture/render_settings.h" // captureActionTable -#include "core/version/app_version.h" // channelCommandId / appVersion +#include "core/version/app_version.h" // appVersion #include "ingest.h" #include "shell/actions/action_registry.h" // the Q-W6 registration table #include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) diff --git a/src/shell/actions/action_registry.cpp b/src/shell/actions/action_registry.cpp index 2112111..00ece1a 100644 --- a/src/shell/actions/action_registry.cpp +++ b/src/shell/actions/action_registry.cpp @@ -61,6 +61,7 @@ int registerAction(reaper_plugin_info_t* rec, const char* suffix, void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows, std::size_t count) { + g_table.clear(); // idempotent-by-construction: a re-register never doubles-up rows for (std::size_t i = 0; i < count; ++i) { g_table.push_back(TableEntry{rows[i]}); TableEntry& e = g_table.back(); diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 91b76e9..a05dfb9 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -29,7 +29,7 @@ #include "core/view/lane_keys.h" // view::isOnManualLane — the single managed/manual predicate #include "core/view/view_mode_model.h" -#include "shell/persist/session.h" // ReaSamplerSession (owns view() model) +#include "shell/persist/session.h" // ReaSamplerSession (owns view() model) #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index fc15bb5..e22e4f9 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -19,7 +19,7 @@ #include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome #include "core/model/bank_book.h" // BankBook / Bank #include "core/model/provenance.h" // recipe parse/build, fingerprint -#include "shell/persist/session.h" // ReaSamplerSession +#include "shell/persist/session.h" // ReaSamplerSession #include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline #include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid #include "shell/capture/scope_resolve.h" // ResolvedSource diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 486cb6e..030e146 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -17,7 +17,7 @@ #include "core/capture/tail_control.h" // TailSetting #include "core/model/provenance.h" // model::Provenance #include "ingest.h" // ingestAssignActiveInstance -#include "shell/persist/session.h" // ReaSamplerSession +#include "shell/persist/session.h" // ReaSamplerSession #include "shell/capture/insert.h" // runInsert / InsertRequest #include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 7edade5..8a2a1b4 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -18,7 +18,7 @@ #include "shell/panel/panel_input.h" #include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) -#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation +#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) @@ -156,11 +156,11 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, // membership index. // // INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a -// background metadata update (like setting a label), not a destructive project edit. -// the persist shell (ext_state_io.cpp) writes it on the next project save alongside the -// bank and view state, the -// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood -// the REAPER undo history with a new entry for every timer tick that sees new content. +// background metadata update (like setting a label), not a destructive project edit. The +// persist shell (ext_state_io.cpp) writes it on the next project save alongside the bank +// and view state, the same way an action-driven tag is persisted. Wrapping this in an Undo +// block would flood the REAPER undo history with a new entry for every timer tick that sees +// new content. // Returns true iff this tick tagged at least one new GUID into a mode — the signal the // caller uses to decide whether to run the lane-minting pass (a track can only newly // become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint. diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index 0a1eceb..34d9311 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -20,7 +20,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_layout.h" -#include "shell/persist/session.h" // ReaSamplerSession — mode/view reads +#include "shell/persist/session.h" // ReaSamplerSession — mode/view reads #include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId() // Action-trigger buttons (M11): resolve each button's command id at runtime from the diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 8e19795..e111169 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -18,7 +18,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1) -#include "shell/persist/session.h" // ReaSamplerSession — mode/view/tail reads +#include "shell/persist/session.h" // ReaSamplerSession — mode/view/tail reads #include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model namespace reasampler::panel { From 4d2316b77c36f28b73dcb02f38d4d4359c1673b1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 13:57:44 -0400 Subject: [PATCH 39/40] =?UTF-8?q?docs(phase-q):=20record=20Q-W4/Q-W5/Q-W6?= =?UTF-8?q?=20landings=20=E2=80=94=20all=20seven=20waves=20structurally=20?= =?UTF-8?q?complete;=20remaining:=20DAW=20verification=20batch,=20CLAUDE.m?= =?UTF-8?q?d=20refresh,=20dev=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMPLETED.md | 128 ++++++++++++++++++++++++++++++++++++++ PLAN.md | 169 +++++++++++++++++++++++++++------------------------ 2 files changed, 216 insertions(+), 81 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index 6ca7464..1b00005 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -3128,3 +3128,131 @@ the CLAUDE.md/CONTEXT description is corrected in the same commit. (`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named follow-up; `ingest.cpp` trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` + `view` remain the shim's unowned consumers). + +--- + +## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w4-actions`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Split the two unrelated command-id families in one TU (1016 LOC at the Q-W0 census — +T4-03: the planned seams still land sub-600, no reshape) into +`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own +`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in +Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to +`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe). +See `docs/product/code-organization.md` §2.1, §2.4. +**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View +family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The +bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each +bank verb still wraps its mutation in one batched undo point; the prune action still writes no +ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE +contract — a reorg must not touch a shipped command id). +**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3. + +- [x] Split `actions.cpp` (1019 LOC) → `design_view_actions` (toggle/activate/tag/untag/ + showBoth/moveItems), `bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — + the single file-deletion action), plus a fourth shared `action_registry` TU under + `shell/actions/`. +- [x] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops` + (one owner); no command-id string changed. Bank verbs reshaped to **promptless inner verbs** + (one mutation home, two UX skins — panel and actions each keep their exact prior UX); + `promptText` renamed `promptBankName`; `persistBankOp`/`persistBook` gain null-session guards. +- [x] `prune_action` verified a clean deletion-authority isolate (no `Undo_*`, no ext-state + writes). Command-id suffixes/display phrases verified byte-identical in review. +- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still + no-undo/no-ext-state; CTest green. — **PENDING**: in-DAW verification not yet performed on + `phase-q` (deferred by design). + +**Notes/decisions:** +- **Review 🟡 (resolved in Q-W6):** two session pointers / a null-session-as-model-rejection + misreport (unreachable today) — resolved by Q-W6's `bank_ops` lift. + +--- + +## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`) (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w5-persist`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Split `persist.cpp` (852 LOC at the Q-W0 census — T4-04: seams unchanged; the +pS-usage growth landed exactly where this wave isolates it; 5 responsibilities) into `session` +(lifecycle+poll, `BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON +serialization bridge + GUID minting + folder relocation), and **`prune_fs`** (prune scanning + +`deleteOrphanFile` via `SHFileOperationW`). The split **concentrates** the byte-deleting +authority into one obvious module — it must never spread it. **Q-W0 rider (T2-04, SETTLED +2026-07-28):** generalize the `GetProjExtState` grow-loop retry policy into `bridge_marshal`'s +pure decode home (or its `core/` successor) and rewire all three hand-rolled copies — +`usage_scan`'s prune-safety-adjacent copy included. CONTEXT.md §Phase Q (persist split seams; +deletion-authority isolation). See `docs/product/code-organization.md` §2.1, §7. +**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip, +folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in +exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved* +(concentrated), never diluted. Relative-paths-only persistence is unchanged. +**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but +independently landable. + +- [x] Split `persist.cpp` (853 LOC) → `session` (lifecycle/poll + `projectconfig` reload hook), + `ext_state_io` (serialization bridge + GUID minting + folder relocation), under `shell/persist/` + + `persist_internal.h`. +- [x] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the + deletion authority concentrated in exactly one anonymous-namespace function in `prune_fs.cpp`, + verified tree-wide; the prune fail-safe chain stays byte-intact. +- [x] Dedupe the `GetProjExtState` grow-loop ×3 (T2-04): unified as a header-only template, all + three copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified + equivalent); the grow-loop gains a defensive NUL. +- [x] Rider: the Q-W1 `bank_book_json` residual lands via a private static `nameKey` + (Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC. +- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one + module; relative-paths-only holds; CTest green. — **PENDING**: in-DAW verification not yet + performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- `persist.h` is kept as a compat umbrella for parallel safety across the in-flight waves + (retired in Q-W6); deletion-authority wording is scoped precisely in headers. + +--- + +## Q-W6 — OCP registration-table + residual fat-header (I) splits (2026-07-29) + +> **Merged to `phase-q` 2026-07-29. Integrated suite 61/61 green, reviewed-approved.** + +**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action +registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so +adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers +(`capture.h`/`persist.h`) not already resolved by their TU splits (I). (Q-W0: no reshape — +T4-02 notes the ~385-line registration residue left in `app/main.cpp` after Q-W3 shrinks +further under the table.) CONTEXT.md §Phase Q (OCP +registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6). +**Verify:** CTest green at every commit. Every action still registers, appears in the Actions +list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged +(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical +new action now touches the table only (demonstrated in review, not shipped). Remaining fat +headers are segmented. +**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most +droppable point if the phase needs narrowing (Q-6). + +- [x] Converted the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a + data-driven `ActionTableRow` registration table (flat function-pointer dispatch, no + `std::function`/virtual); unload mirror-unregisters from the same table; `main.cpp` shrinks + 653→404. Capture rows derive their suffix+phrase from the pure `captureActionTable()` (the + parallel-list risk is gone by construction). FOREVER-STABLE suffixes/phrases/retired-ids + verified byte-identical row-by-row in review. +- [x] Split residual fat headers: `persist.h` umbrella retired (13 callers repointed); + `capture.h`'s realtime seam moved to `capture_realtime_shell.h`; the `wav_trim.h` shim + its + INTERFACE target deleted. +- [x] Phase-end cleanup riders: `bankOp*` verbs + `persistBankOp` lifted to new `shell/bank_ops` + taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note); **`core/namespaces.h` + DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers rewired); the grow-loop + rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep (`persist.cpp`/`bank_panel.cpp` + refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets corrected in-wave. +- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest + green. — **PENDING**: in-DAW verification not yet performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- **Review-noted follow-on (not landed, deferred):** extending the table pattern to the + design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan + is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't + required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split + stays optional/deferred). diff --git a/PLAN.md b/PLAN.md index 703d1a8..1f23b50 100644 --- a/PLAN.md +++ b/PLAN.md @@ -313,6 +313,15 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > a file move + namespace change is mechanically verifiable — `ctest --test-dir build` is green > or it isn't. **Green-CTest-at-every-point is an acceptance criterion.** Big-bang is rejected; > the reorg is risk-ordered waves (W1 safe opener → W2/W2v–W5 god-module splits → W6 OCP finish). +> +> **PHASE STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) are structurally +> COMPLETE.** Remaining before the phase closes and merges to `dev`: (1) Daniel's in-DAW +> verification batch — the full deferred list across all waves (panel parity, editor/processor +> parity, stereo Preserve listening, null test, bit-identical repeats, capture flows, action +> families, one-op-one-Ctrl-Z, prune fail-safes, save/load/relocation) — now unblocked since the +> tree is stable; (2) the phase-close CLAUDE.md architecture refresh (module map still describes +> some pre-Q homes); (3) the phase-q → dev merge on Daniel's sign-off. See `COMPLETED.md` for +> each wave's full landed narrative. ## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1) **STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now @@ -475,78 +484,67 @@ before/after listening or null check. **The gate to Q-W1 is: triage complete + D > by design, not yet performed. ## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` -**Goal:** Split the two unrelated command-id families in one TU (1016 LOC at the Q-W0 census — -T4-03: the planned seams still land sub-600, no reshape) into -`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own -`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in -Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to -`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe). -See `docs/product/code-organization.md` §2.1, §2.4. -**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View -family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The -bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each -bank verb still wraps its mutation in one batched undo point; the prune action still writes no -ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE -contract — a reorg must not touch a shipped command id). -**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3. -- [ ] Split → `design_view_actions` (toggle/activate/tag/untag/showBoth/moveItems), - `bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — the single - file-deletion action). -- [ ] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops` - (one owner); do **not** change any command-id string. -- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still - no-undo/no-ext-state; CTest green. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w4-actions`). Integrated suite 61/61 green, +> reviewed-approved.** `actions.cpp` (1019 LOC) split into `design_view_actions` / `bank_actions` +> / `prune_action`, plus a fourth shared `action_registry` TU, all under `shell/actions/`; +> `promptText`/`mintBankId` deduped against `panel_bank_ops`; bank verbs reshaped to promptless +> inner verbs (one mutation home, two UX skins — panel and actions each keep their exact prior +> UX); command-id suffixes/display phrases verified byte-identical in review; `prune_action` +> stays a clean deletion-authority isolate (no `Undo_*`, no ext-state writes); +> `persistBankOp`/`persistBook` gain null-session guards; `promptText` renamed `promptBankName`. +> See `COMPLETED.md` for the full narrative. +> +> **Review note (🟡, resolved in Q-W6):** two session pointers / a null-session-as-model-rejection +> misreport (unreachable today) was resolved by Q-W6's `bank_ops` lift. +> +> **In-DAW verification (action families, one-op-one-Ctrl-Z, prune fail-safes) is PENDING on +> `phase-q`** — deferred by design, not yet performed. ## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`) -**Goal:** Split `persist.cpp` (852 LOC at the Q-W0 census — T4-04: seams unchanged; the -pS-usage growth landed exactly where this wave isolates it; 5 responsibilities) into `session` -(lifecycle+poll, `BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON -serialization bridge + GUID minting + folder relocation), and **`prune_fs`** (prune scanning + -`deleteOrphanFile` via `SHFileOperationW`). The split **concentrates** the byte-deleting -authority into one obvious module — it must never spread it. **Q-W0 rider (T2-04, SETTLED -2026-07-28):** generalize the `GetProjExtState` grow-loop retry policy into `bridge_marshal`'s -pure decode home (or its `core/` successor) and rewire all three hand-rolled copies — -`usage_scan`'s prune-safety-adjacent copy included. CONTEXT.md §Phase Q (persist split seams; -deletion-authority isolation). See `docs/product/code-organization.md` §2.1, §7. -**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip, -folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in -exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved* -(concentrated), never diluted. Relative-paths-only persistence is unchanged. -**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but -independently landable. -- [ ] Split → `session` (lifecycle/poll + `projectconfig` reload hook), `ext_state_io` - (serialization bridge + GUID minting + folder relocation). -- [ ] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the - one file-deletion module; nothing else may delete bytes. -- [ ] Dedupe the `GetProjExtState` grow-loop ×3 (T2-04): one retry policy generalized from - `bridge_marshal`; rewire `usage_scan`'s prune-safety-adjacent copy with `sample_usage_tests` - green. -- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one - module; relative-paths-only holds; CTest green. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w5-persist`). Integrated suite 61/61 green, +> reviewed-approved.** `persist.cpp` (853 LOC) split into `session` / `ext_state_io` / `prune_fs` +> under `shell/persist/` + `persist_internal.h`; the file-deletion authority is concentrated — +> `SHFileOperationW`/orphan-remove lives in exactly one anonymous-namespace function in +> `prune_fs.cpp`, verified tree-wide; the prune fail-safe chain stays byte-intact. T2-04's +> `GetProjExtState` grow-loop is unified as a header-only template, with all three hand-rolled +> copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified +> equivalent). The Q-W1 `bank_book_json` residual lands via a private static `nameKey` +> (Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC. `persist.h` is kept as a compat +> umbrella for parallel safety (retired in Q-W6); deletion-authority wording is scoped precisely +> in headers; the grow-loop gains a defensive NUL. See `COMPLETED.md` for the full narrative. +> +> **In-DAW verification (save/load/undo-reload, ext-state round-trip, folder relocation, prune +> deletion) is PENDING on `phase-q`** — deferred by design, not yet performed. ## Q-W6 — OCP registration-table + residual fat-header (I) splits -**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action -registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so -adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers -(`capture.h`/`persist.h`) not already resolved by their TU splits (I). (Q-W0: no reshape — -T4-02 notes the ~385-line registration residue left in `app/main.cpp` after Q-W3 shrinks -further under the table.) CONTEXT.md §Phase Q (OCP -registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6). -**Verify:** CTest green at every commit. Every action still registers, appears in the Actions -list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged -(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical -new action now touches the table only (demonstrated in review, not shipped). Remaining fat -headers are segmented. -**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most -droppable point if the phase needs narrowing (Q-6). -- [ ] Convert the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a - data-driven registration table; unload mirror-unregisters from the same table. -- [ ] Split residual fat headers (`capture.h`/`persist.h` and any other) alongside their TUs (I). -- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest - green. +> **Landed on `phase-q` (2026-07-29). Integrated suite 61/61 green, reviewed-approved.** Action +> registration/gaccel/hookcommand-dispatch/mirror-unregister all iterate one `ActionTableRow` +> table (flat function-pointer dispatch, no `std::function`/virtual); adding a new action now +> touches one table row only; `main.cpp` shrinks 653→404. FOREVER-STABLE suffixes/phrases/ +> retired-ids verified byte-identical row-by-row in review; capture rows derive their +> suffix+phrase from the pure `captureActionTable()` (the parallel-list risk is gone by +> construction). See `COMPLETED.md` for the full narrative. +> +> **Phase-end cleanup riders (landed in this wave):** `bankOp*` verbs + `persistBankOp` lifted to +> new `shell/bank_ops` taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note); +> `persist.h` umbrella retired (13 callers repointed); `capture.h`'s realtime seam moved to +> `capture_realtime_shell.h`; the `wav_trim.h` shim + its INTERFACE target deleted; +> **`core/namespaces.h` DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers +> rewired); the grow-loop rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep +> (`persist.cpp`/`bank_panel.cpp` refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets +> corrected in-wave. +> +> **Review-noted follow-on (not landed, deferred):** extending the table pattern to the +> design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan +> is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't +> required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split +> stays optional/deferred). +> +> **In-DAW verification (all action families, registration/fire/unregister parity) is PENDING on +> `phase-q`** — deferred by design, not yet performed. ## Phase Q — sequencing ``` @@ -554,25 +552,34 @@ GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 abandoned) — tree qu ("when Phase S and L3 are finished" — L1/L2/L3/L4–L7 all landed — GATE SATISFIED) │ ▼ -Q-W0 (audit + triage + report — COMPLETE; all 59 dispositions signed off 2026-07-28) - │ ── SUB-GATE: satisfied once the six approved fix-now remediations land ── - ▼ (T1-01 T1-03 T1-09 T2-01a T3-01 T3-03 — in flight on pq-w0-fixes) +Q-W0 (audit + triage + report — COMPLETE; all 59 dispositions signed off 2026-07-28; + │ fix-now remediations LANDED 2026-07-28) + ▼ Q-W1 (safe opener: core/json ×5 + wire codec + rect unification + relocation incl. ~20 VST - │ pure libs under core/instrument/{engine,map,ui} + riders) - ├─► Q-W2 (split bank_panel — 8 seams) ──► Q-W4 (split actions + dedupe vs panel_bank_ops) - ├─► Q-W2v (NEW: VST god-modules — editor 8 TUs / processor 3 TUs / component_state_io; - │ sampler_core TU whole — documented exception) [parallel with Q-W2: zero overlap] + │ pure libs under core/instrument/{engine,map,ui} + riders — LANDED 2026-07-29) + ├─► Q-W2 (split bank_panel — 8 seams — LANDED 2026-07-29) + │ └─► Q-W4 (split actions + dedupe vs panel_bank_ops — LANDED 2026-07-29) + ├─► Q-W2v (VST god-modules — editor 8 TUs / processor 3 TUs / component_state_io; + │ sampler_core TU whole — documented exception — LANDED 2026-07-29) + │ [parallel with Q-W2: zero overlap] ├─► Q-W3 (split main — 4 hoists incl. capture_batch; + wav_codec, ICaptureBackend deletion, - │ stamp dedupe, T1-11, capture_realtime_finalize) ──► Q-W6 (OCP registration-table) - └─► Q-W5 (split persist; + ext-state-loop dedupe) [best after Q-W4] + │ stamp dedupe, T1-11, capture_realtime_finalize — LANDED 2026-07-29) + │ └─► Q-W6 (OCP registration-table — LANDED 2026-07-29) + └─► Q-W5 (split persist; + ext-state-loop dedupe — LANDED 2026-07-29) [best after Q-W4] + +STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) structurally COMPLETE, 61/61 +integrated suite green. Remaining: Daniel's in-DAW verification batch, the phase-close +CLAUDE.md architecture refresh, and the phase-q → dev merge on sign-off. ``` -Q-W0 has run and is signed off (2026-07-28); its sub-gate closes when the six fix-now -remediations land. W1 is the safe, high-leverage structural opener (all later waves assume the -layout — including the T4-18 `instrument/` placement — it establishes). The god-module splits -(W2, W2v, W3, W5) are risk-ordered and mostly parallel-safe; **Q-W2v runs parallel with Q-W2** -(different artifact, zero file overlap — audit §4f SETTLED); W4 depends on W2's -`panel_bank_ops`, W6 depends on W3's isolated registration code. Big-bang is rejected — every -wave is independently landable and CTest-green. +Q-W0 ran and closed 2026-07-28 (its six fix-now remediations landed the same day). W1 was the +safe, high-leverage structural opener (all later waves assumed the layout — including the T4-18 +`instrument/` placement — it establishes). The god-module splits (W2, W2v, W3, W5) were +risk-ordered and mostly parallel-safe; **Q-W2v ran parallel with Q-W2** (different artifact, zero +file overlap — audit §4f SETTLED); W4 depended on W2's `panel_bank_ops`, W6 depended on W3's +isolated registration code. Big-bang was rejected — every wave landed independently, +CTest-green throughout. **All seven waves landed on `phase-q` by 2026-07-29 — Phase Q is +structurally complete** (see the phase preamble's PHASE STATUS block for what remains before the +phase closes and merges to `dev`). ## Phase Q — must-verify-before-build - **Q-W0 closed before any structural point** — the functional/DSP audit's findings report exists, From f52955467354dedcf8aa8981ea4b138149bc0a78 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 14:06:56 -0400 Subject: [PATCH 40/40] =?UTF-8?q?docs:=20CLAUDE.md=20post-Phase-Q=20archit?= =?UTF-8?q?ecture=20refresh=20=E2=80=94=20core/shell/app=20module=20map,?= =?UTF-8?q?=20split-TU=20realities,=20registration-table=20mechanism;=20co?= =?UTF-8?q?ntract=20text=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 61cc31c..4b1cc1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repo identity and current state -**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`, `src/vst/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout. CONTEXT.md is the authoritative spec — settled decisions, invariants, guardrails, and not-yet-built specs; it is large, so locate the relevant phase section by grepping its headings and read only that section with an offset rather than reading it whole. Build detail for landed phases lives in CONTEXT-ARCHIVE.md. Every REAPER API name cited there is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. A post-S-VIEW DAW-fix pass has landed (all 52 suite tests green): envelope nodes fully editable in both modes (every Gate stage A/H/D/S/R + Trigger zero-fade-out node, param-domain schematic scaling, 8 px min node separation, all nodes clamped in-canvas); gap-free per-column waveform render (`columnMinMax` homed in `peaks`, `waveformColumnCount` in `component_geometry`, shared via `drawWaveform`); `param_slider` `Knob` primitive (7→5 o'clock arc, needle, vertical-drag); zone-bleed fix 3a (`reconcileSingleCaptureZones` in `sample_map`). The voice-system redesign is also landed: `sampler_core` gains user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` retrigger/legato toggle), an isolated `PreviewCard` (dedicated preview voice outside the MIDI pool — never steals from/into it; unity-Preserve zero-latency bypass scoped to it), and two-tier panic (CC 123 = release, CC 120 = immediate hard-stop incl. Trigger one-shots); processor sums the preview card alongside the engine + drain, `retireIdleDrain()` retires fully-idle drain snapshots, and voice-param edits rebuild from the already-decoded PCM (no bank re-read/WAV re-decode) via the drain-slot swap; `ComponentState` envelope bumped v6→v7 (voiceCount/voiceMode/monoTrigger bytes; pre-v7 blobs lift to 16/Poly/Retrigger). **FB1 Sample-view recomposition (r11) has also landed** (suite 55/55 green): all linear sliders replaced by radial **knobs** in a fenced **knob deck** (groups: AMP ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER); mode toggles are compact in the caption row, not full-width; the **hero waveform runs full-width** (elastic band, 840×620 default preserved); the inline velocity-curve box is replaced by a **28×28 curve preview button → centered popup** with right-click node delete; voice-band controls (count / Poly-Mono / Retrig-Legato) are placed in the VOICE deck group; a **post-mixer per-sample-ramped master gain** (−∞…+24 dB, no zipper) is placed in the MASTER deck group, persisted as `masterGainLinear` — `ComponentState` envelope bumped v7→v8 (pre-v8 blobs lift to unity gain). Three new pure `src/vst/` modules landed: `knob_deck` (group-box + caption-row + knob-cell geometry, deterministic wrap, hit-test), `curve_popup` (sheet/close/box geometry + outside-sheet dismissal test), `master_gain` (dB↔linear taper math, −∞…+24 dB). **FB2 Zone-panel parity (r11, 2026-07-28) has also landed** (suite 55/55 green): the Zone param panel now uses the same knob deck + curve-preview-button/popup grammar as the Sample face — one control grammar across both surfaces of the one per-zone storage site; Zone-authoring affordances (+Add Zone / Delete, the piano-key strip, Low/High/Root legend) are preserved; VOICE and MASTER groups remain Sample-only (per-instance). `param_slider`'s linear slider rows are retired on the Zone panel (the FA4 `Knob` primitive is now the only live consumer of that half of `param_slider`). **This completes the r11 editor recomposition (Wave B / Phase S editor redesign).** A **GA post-launch DAW-fix pass** has also landed (suite 55/55 green): `pitch_shift` rewritten from dual-tap OLA (anti-phase cancellation → spectral garbage on repitched notes) to **correlation-aligned SOLA splices** with a ratio-scaled raised-cosine fade (clean pitch shift past +24 st); `Voice::start` applies a **bounded blend** (`out*(1-w) + ref*w`, w decaying from 1.0) at takeover boundaries — mono retrig/fallback, poly at-cap steal, and preview re-trigger — superseding the earlier `(1-amp)` envelope-complement gate that zeroed the compensation on Trigger/zero-attack restarts; the output bus is now **permanently stereo** (`ChannelMode` is decode-only; the dynamic mono↔stereo bus renegotiation is deleted) with channel mode **auto-defaulting from the loaded capture** via new `ComponentState` **v9** (`channelModeExplicit` flag) + a pure `channelModeFor` helper; `SetCapture` moved to drag-arm in `bank_panel` so a first straight-out drag arms correctly; and the Design/Arrange mode-toggle action now calls `bankPanelInvalidate()` so the panel footer reflects the new mode without requiring a button click. **Preview via real MIDI note path (pS, 2026-07-28):** the dedicated `PreviewCard` is RETIRED; preview now injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` (same path host MIDI uses), so it obeys polyphony/mono/voice-stealing/envelopes; the processor no longer sums a separate preview voice; the unity-Varispeed-bypass demotion (GA2 primed shifter speaks on frame 0 anyway) is removed. **Self-contained playback (pS, 2026-07-28):** `ComponentState` bumped v9→**v10** with a `SampleRefs` table — per referenced sample, the instance owns a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent); the bank/bridge is now a browser source (loading a capture copies its reference in); the reopen-heal timer + poll-to-play apparatus are removed; pre-v10 blobs lift to empty refs and re-save self-contained. **Usage-detection (pS-usage, 2026-07-28):** new pure module `sample_usage` + REAPER-facing shell `usage_scan` implement the instance-usage wire (`rsusage_` per-instance ext-state keys); each live ReaSampler 9000 instance publishes the captures it holds (from its `SampleRefs`) at `reloadInstrument` time; the extension reads every usage record at prune-scan time, liveness-folds against the live FX enumeration, and unions the surviving held paths into the prune's referenced set — so a capture held by any live instance can never be an orphan and `BANK_PRUNE_FOLDER` can never delete it; `prune_reconcile` gains `mergeReferenced`; `persist`'s `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) on unreadable usage; `actions` halts and prints offending keys; `reaper_bridge` gains `writeUsageExtState` (prefix-guarded — refuses non-`rsusage_` keys); `ComponentState` bumped v10→**v11** (`instanceGuid` field; pre-v11 blobs mint guid on first publish); new `sample_usage_tests` build target (pure, no REAPER/DAW). +**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. CONTEXT.md is the authoritative spec — settled decisions, invariants, guardrails, and not-yet-built specs; it is large, so locate the relevant phase section by grepping its headings and read only that section with an offset rather than reading it whole. Build detail for landed phases lives in CONTEXT-ARCHIVE.md. Every REAPER API name cited there is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. A post-S-VIEW DAW-fix pass has landed (all 52 suite tests green): envelope nodes fully editable in both modes (every Gate stage A/H/D/S/R + Trigger zero-fade-out node, param-domain schematic scaling, 8 px min node separation, all nodes clamped in-canvas); gap-free per-column waveform render (`columnMinMax` homed in `peaks`, `waveformColumnCount` in `component_geometry`, shared via `drawWaveform`); `param_slider` `Knob` primitive (7→5 o'clock arc, needle, vertical-drag); zone-bleed fix 3a (`reconcileSingleCaptureZones` in `sample_map`). The voice-system redesign is also landed: `sampler_core` gains user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` retrigger/legato toggle), an isolated `PreviewCard` (dedicated preview voice outside the MIDI pool — never steals from/into it; unity-Preserve zero-latency bypass scoped to it), and two-tier panic (CC 123 = release, CC 120 = immediate hard-stop incl. Trigger one-shots); processor sums the preview card alongside the engine + drain, `retireIdleDrain()` retires fully-idle drain snapshots, and voice-param edits rebuild from the already-decoded PCM (no bank re-read/WAV re-decode) via the drain-slot swap; `ComponentState` envelope bumped v6→v7 (voiceCount/voiceMode/monoTrigger bytes; pre-v7 blobs lift to 16/Poly/Retrigger). **FB1 Sample-view recomposition (r11) has also landed** (suite 55/55 green): all linear sliders replaced by radial **knobs** in a fenced **knob deck** (groups: AMP ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER); mode toggles are compact in the caption row, not full-width; the **hero waveform runs full-width** (elastic band, 840×620 default preserved); the inline velocity-curve box is replaced by a **28×28 curve preview button → centered popup** with right-click node delete; voice-band controls (count / Poly-Mono / Retrig-Legato) are placed in the VOICE deck group; a **post-mixer per-sample-ramped master gain** (−∞…+24 dB, no zipper) is placed in the MASTER deck group, persisted as `masterGainLinear` — `ComponentState` envelope bumped v7→v8 (pre-v8 blobs lift to unity gain). Three new pure `src/vst/` modules landed: `knob_deck` (group-box + caption-row + knob-cell geometry, deterministic wrap, hit-test), `curve_popup` (sheet/close/box geometry + outside-sheet dismissal test), `master_gain` (dB↔linear taper math, −∞…+24 dB). **FB2 Zone-panel parity (r11, 2026-07-28) has also landed** (suite 55/55 green): the Zone param panel now uses the same knob deck + curve-preview-button/popup grammar as the Sample face — one control grammar across both surfaces of the one per-zone storage site; Zone-authoring affordances (+Add Zone / Delete, the piano-key strip, Low/High/Root legend) are preserved; VOICE and MASTER groups remain Sample-only (per-instance). `param_slider`'s linear slider rows are retired on the Zone panel (the FA4 `Knob` primitive is now the only live consumer of that half of `param_slider`). **This completes the r11 editor recomposition (Wave B / Phase S editor redesign).** A **GA post-launch DAW-fix pass** has also landed (suite 55/55 green): `pitch_shift` rewritten from dual-tap OLA (anti-phase cancellation → spectral garbage on repitched notes) to **correlation-aligned SOLA splices** with a ratio-scaled raised-cosine fade (clean pitch shift past +24 st); `Voice::start` applies a **bounded blend** (`out*(1-w) + ref*w`, w decaying from 1.0) at takeover boundaries — mono retrig/fallback, poly at-cap steal, and preview re-trigger — superseding the earlier `(1-amp)` envelope-complement gate that zeroed the compensation on Trigger/zero-attack restarts; the output bus is now **permanently stereo** (`ChannelMode` is decode-only; the dynamic mono↔stereo bus renegotiation is deleted) with channel mode **auto-defaulting from the loaded capture** via new `ComponentState` **v9** (`channelModeExplicit` flag) + a pure `channelModeFor` helper; `SetCapture` moved to drag-arm in `bank_panel` so a first straight-out drag arms correctly; and the Design/Arrange mode-toggle action now calls `bankPanelInvalidate()` so the panel footer reflects the new mode without requiring a button click. **Preview via real MIDI note path (pS, 2026-07-28):** the dedicated `PreviewCard` is RETIRED; preview now injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` (same path host MIDI uses), so it obeys polyphony/mono/voice-stealing/envelopes; the processor no longer sums a separate preview voice; the unity-Varispeed-bypass demotion (GA2 primed shifter speaks on frame 0 anyway) is removed. **Self-contained playback (pS, 2026-07-28):** `ComponentState` bumped v9→**v10** with a `SampleRefs` table — per referenced sample, the instance owns a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent); the bank/bridge is now a browser source (loading a capture copies its reference in); the reopen-heal timer + poll-to-play apparatus are removed; pre-v10 blobs lift to empty refs and re-save self-contained. **Usage-detection (pS-usage, 2026-07-28):** new pure module `sample_usage` + REAPER-facing shell `usage_scan` implement the instance-usage wire (`rsusage_` per-instance ext-state keys); each live ReaSampler 9000 instance publishes the captures it holds (from its `SampleRefs`) at `reloadInstrument` time; the extension reads every usage record at prune-scan time, liveness-folds against the live FX enumeration, and unions the surviving held paths into the prune's referenced set — so a capture held by any live instance can never be an orphan and `BANK_PRUNE_FOLDER` can never delete it; `prune_reconcile` gains `mergeReferenced`; `persist`'s `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) on unreadable usage; `actions` halts and prints offending keys; `reaper_bridge` gains `writeUsageExtState` (prefix-guarded — refuses non-`rsusage_` keys); `ComponentState` bumped v10→**v11** (`instanceGuid` field; pre-v11 blobs mint guid on first publish); new `sample_usage_tests` build target (pure, no REAPER/DAW). **Phase Q (2026-07-29, landed on `phase-q`)** reorganized all of `src/` into `core/` (pure, subsystem-namespaced: `model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`/`util`/`wire`/`instrument/{engine,map,ui}`), `shell/` (REAPER/host-facing: `capture`/`panel`/`view`/`persist`/`actions`/`instrument`/`bank_ops`), and `app/` (`main.cpp`), splitting several god-modules along the way (`bank_panel` into eight `shell/panel/` TUs, the VST3 processor/editor god-TUs, `capture.cpp`, and `persist.cpp`) under a soft ~600-line-per-TU ceiling; `src/vst/` no longer exists. ## One-time submodule setup @@ -54,6 +54,11 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde ## Architecture: the load-bearing split **Pure core (no REAPER types, unit-testable outside the DAW):** +- `json` (`core/json`) — the ONE hand-rolled JSON lexical layer (Q-W1): string/number/bool/null tokens, the scoped object `Writer`, and the bounds-checked `Reader` cursor, byte-compatible with the five pre-extraction per-module writers it replaced (`bank_model` / `bank_book` / `view_mode_model` / `owned_manifest` / `tail_control`). Domain grammars stay in the consumers; this owns lexing/emitting only. +- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory. +- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only. +- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound. +- `rect` (`core/ui`, header-only) — the ONE concrete pixel rectangle (Q-W1): XYWH storage + `right()`/`bottom()`/`ltrb()`/`contains()`, replacing 12+ byte-identical role structs (`ButtonRect`/`FooterRect`/`CellRect`/`KitBox`/…) and the VST side's separate LTRB `Rect`; every prior role name survives as a `using` alias at its old site (e.g. `editor_geometry::Rect`). - `bank_model` — `Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart. - `peaks` — waveform min/max bin computation from raw PCM; does not depend on REAPER's peak API. - `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip. @@ -61,10 +66,12 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel. - `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip. - `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch. +- `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`. - `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface. - `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files. - `app_version` — REAPER-free version/channel identity: CMake-sourced semver constant, ext-state stamp value, and the full set of channel-derived identity accessors. All channel strings derive from one `REASAMPLER_CHANNEL_IS_BETA` bit; no scattered `#ifdef`s in the shells. - `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner). +- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`). - `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** - `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them. - `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button. @@ -86,8 +93,15 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde **REAPER-facing shells:** - `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. +- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). +- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + owned-manifest record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). +- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. +- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. +- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). +- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`. - `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** -- `bank_panel` — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. +- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU. +- `bank_ops` (`shell/bank_ops`) — the promptless bank-mutation verb seam (Q-W6 lift out of `panel_bank_ops`): `bankOpCreate`/`Rename`/`Delete`/`Evacuate`/`Activate`/`Transfer`/`Remove` + `persistBankOp` (the undo-batched ext-state persist), each taking a `ReaSamplerSession&` and returning whether the model accepted the mutation — no prompts, no message boxes, no panel-state reads. `shell/panel/panel_bank_ops` (menu/prompt UX) and `shell/actions/bank_actions` (bindable-action UX) both consume these as thin skins, so the mutation logic has exactly one home. - `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `prune_fs` hosts the prune dry-run / full-set orphan queries (supplying `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. **pS-usage:** the prune scan unions instance usage via `usage_scan`; `PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. - `usage_scan` — extension-side prune-scan shell (pS-usage): at prune-scan time, enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and folds with `sample_usage::foldUsageRecords` / `usageHeldPaths` to produce the set of held paths — or `abortPrune` when any record is unreadable (fail-safe: an unreadable record may protect anything, so the prune halts). Feeds `prune_reconcile::mergeReferenced`. Read-only: writes no ext-state. - `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. **Never touches master or `B_MUTE`/`I_SOLO`.** @@ -99,13 +113,14 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `draw_kit` — shared LICE draw shell: `fillSurface`, `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`, cached-font `text()`, full interaction-state model, double-buffer preserved. Consumes `theme` + `component_geometry`. - `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions. -**VST3 instrument (`src/vst/`) — pure core:** +**VST3 instrument (`core/instrument/{engine,map,ui}`, plus shared substrate in `core/{audio,ui,wire}`) — pure core:** - `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. 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. - `sample_map` — zone payload: zones keyed by note range. **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). JSON round-trip. +- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), 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 (`sampler_core`/`pitch_shift`) 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. `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share. - `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()`. - `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. -- `editor_geometry` — VST3 editor layout: defines the shared `Rect` type + `contains()` hit-test; provides `EditorLayout` and `layoutEditor(w,h)`. +- `editor_geometry` (`core/instrument/ui`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes. - `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry. - `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap. - `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing. @@ -120,10 +135,10 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `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. - `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge. -**VST3 instrument (`src/vst/`) — shells:** +**VST3 instrument (`shell/instrument/`) — shells:** - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant. -- `reasampler_processor` — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. -- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred). +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. +- `reasampler_editor` (`shell/instrument/`: eight face-axis TUs — `editor_session` session/bridge state, `editor_controls` parameter plumbing, `editor_paint_sample`/`editor_paint_browse_zone` paint, `editor_input_sample`/`editor_input_browse_zone` input, `editor_platform` IPlugView/Win32 window plumbing, plus the pure `editor_geometry` layout hoist as the eighth axis; shared internals in `editor_internal.h`, no TU of its own — Q-W2v, T4-11 split of the former god-TU) — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout/hit-test to `embed_strip`. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. @@ -131,7 +146,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - Exactly **one** translation unit defines `REAPERAPI_IMPLEMENT` — that is `main.cpp`. Every other `.cpp` includes `reaper_plugin_functions.h` without the define and gets `extern` declarations for the global API function pointers. - REAPER dlopen()s any `reaper_*.dll|dylib|so` found in `UserPlugins/` and calls the `ReaperPluginEntry` export (produced by `REAPER_PLUGIN_ENTRYPOINT`). `rec->GetFunc` resolves API pointers; `rec->Register` plugs extension callbacks in. -- Action registration pattern (preserve this for all new actions): +- Action registration pattern (preserve this for all new actions). Since Q-W6, `main.cpp`'s own action family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is driven by a data-driven table in `shell/actions/action_registry` (`ActionTableRow`: suffix, phrase, flat function-pointer handler) — registration, `hookcommand` dispatch, and the unload mirror-unregister all iterate that SAME table, so adding an action touches one table row rather than three separate mechanisms. The other action families (`shell/actions/design_view_actions`, `bank_actions`, `prune_action`) register through their own TUs the same way. The per-step contract below is unchanged: 1. `rec->Register("command_id", (void*)"STABLE_FOREVER_STRING")` — mints a persistent command id. **Never change this string after shipping**; user keybindings key off it. Since Phase V (V4), ids and display names are composed via `channelCommandId(suffix)` and `channelActionName(phrase)` from `app_version` — the FOREVER-STABLE contract applies per channel (stable and beta each have their own permanent id family). 2. `rec->Register("gaccel", &accel)` — puts the action in the Actions list. 3. `rec->Register("hookcommand", ...)` — receives every action fired; claim only your own id, return `false` otherwise.